openapi: 3.0.0
info:
  title: DPF API
  version: 1.1.0
  description: |
    ## Common error responses

    Unless otherwise noted, any authenticated endpoint may also return the following
    responses, each with the standard `ErrorResponse` body. To avoid repetition they are
    not listed individually on each operation:

    - **400 Bad Request** — malformed JSON, unknown action, or missing/invalid fields
    - **401 Unauthorized** — missing or invalid JWT bearer token
    - **402 Payment Required** — the account has insufficient credits or an inactive subscription
    - **403 Forbidden** — authenticated but not permitted (no access to the resource, or a higher access level / plan tier is required)

    Operation-specific responses (e.g. `404`, `409`) are documented on each endpoint.
  contact:
    name: Contact DPF
    url: mailto:contact@dpf-it.com
  license:
    name: Proprietary
    url: https://dpf-it.com/#terms

servers:
  - url: https://api.dpf-it.com
    description: Production API

tags:
  - name: Authentication
    description: User authentication and registration endpoints
  - name: Account
    description: Account management endpoints (password reset, etc.)
  - name: Workspaces
    description: Workspace management and collaboration endpoints
  - name: Connections
    description: Workspace external connections (e.g. SFTP) with managed keypairs
  - name: Job Triggers
    description: Scheduled ingestion triggers (e.g. SFTP polling) with AI-generated pre/post-processing code
  - name: Data Specs
    description: Data transformation pipeline management and orchestration
  - name: Iceberg REST Catalog
    description: |
      Apache Iceberg REST Catalog proxy with JWT authentication and workspace-scoped namespace access control.
      Provides standard Iceberg REST Catalog API access to your Iceberg tables.
  - name: Query
    description: |
      DuckDB-powered SQL query interface for executing analytical queries against Iceberg tables.
      Queries are scoped to a single authorized namespace per request.
  - name: Contact
    description: |
      Contact form endpoint with two modes: authenticated (user info auto-populated from account)
      and unauthenticated (email required in request body). No authentication required to submit.
  - name: OAuth 2.1 Authorization Server
    description: |
      PKCE-only OAuth 2.1 authorization server with Dynamic Client Registration (DCR) and refresh
      token rotation. Used by remote MCP clients (VS Code, Cursor, Claude, ChatGPT) to obtain a
      DPF access token via a real browser login — no password ever passes through an LLM/chat
      context. Access tokens are the same 24h user JWT issued by `/auth/login`.
  - name: MCP
    description: |
      Remote Streamable HTTP MCP (Model Context Protocol) server. A single JSON-RPC endpoint,
      authenticated with a Bearer token from either the OAuth 2.1 Authorization Server (recommended)
      or a plain `/auth/login` JWT. Stateless — no session is held between requests.

paths:
  /auth/register:
    post:
      tags:
        - Authentication
      summary: Register new user
      description: Create a new user account
      operationId: registerUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
      responses:
        '201':
          description: User registered successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegisterResponse'
        '409':
          description: Email already registered
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /auth/verify-otp:
    post:
      tags:
        - Authentication
      summary: Verify email with OTP
      description: Verify user email address using OTP sent to their email
      operationId: verifyOtp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VerifyOtpRequest'
      responses:
        '200':
          description: Email verified successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerifyOtpResponse'
        '404':
          description: User not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /auth/resend-otp:
    post:
      tags:
        - Authentication
      summary: Resend verification OTP
      description: Resend the registration verification code to an unverified account. Always returns a generic success message so the endpoint does not reveal whether an account exists.
      operationId: resendOtp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              properties:
                email:
                  type: string
                  format: email
                  description: Email address of the unverified account
                  example: user@example.com
      responses:
        '200':
          description: Generic acknowledgement (sent if an unverified account exists)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: If an unverified account exists for this email, a new code has been sent.
        '429':
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /auth/login:
    post:
      tags:
        - Authentication
      summary: User login
      description: Authenticate user and get JWT token
      operationId: loginUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Login successful
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        '404':
          description: User not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /auth/refresh:
    post:
      tags:
        - Authentication
      summary: Refresh an access token
      description: |
        Exchange a refresh token for a new access token + refresh token pair. The presented refresh
        token is rotated (invalidated on use) — reuse of an already-rotated token revokes the entire
        token family, forcing re-login. No PKCE/redirect dance needed; this is the first-party
        equivalent of the OAuth 2.1 Authorization Server's `grant_type=refresh_token` for the DPF web
        frontend, sharing the same underlying token store.
      operationId: refreshToken
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefreshTokenRequest'
      responses:
        '200':
          description: Token refreshed successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RefreshTokenResponse'
        '401':
          description: Refresh token is unknown, expired, or was already used (token family revoked)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /auth/request-password-reset:
    post:
      tags:
        - Account
      summary: Request password reset
      description: Request a password reset. OTP will be sent to user email
      operationId: requestPasswordReset
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RequestPasswordResetRequest'
      responses:
        '200':
          description: Password reset requested
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RequestPasswordResetResponse'

  /auth/reset-password:
    post:
      tags:
        - Account
      summary: Reset password
      description: Reset user password using OTP from email
      operationId: resetPassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPasswordRequest'
      responses:
        '200':
          description: Password reset successful
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResetPasswordResponse'
        '404':
          description: User not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /auth/settings:
    get:
      tags:
        - Account
      summary: Get user settings
      description: Returns the authenticated user's profile and preferences (name, auto top-off).
      operationId: getSettings
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Settings retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetSettingsResponse'
        '404':
          description: User not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    put:
      tags:
        - Account
      summary: Update user settings
      description: |
        Updates the authenticated user's profile and preferences. Any combination of
        `firstName`, `lastName`, and `topOffEnabled` may be provided; only the fields
        present in the request body are changed.
      operationId: updateSettings
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateSettingsRequest'
      responses:
        '200':
          description: Settings updated successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetSettingsResponse'
        '404':
          description: User not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /auth/notification-preferences:
    get:
      tags:
        - Account
      summary: List notification preferences
      description: |
        Returns the notification message-type registry (each type's label, category,
        eligible roles, and which roles receive it by default) plus the authenticated
        user's explicit preference overrides. A `(workspace, messageType)` with no
        override row follows the type's default.
      operationId: listNotificationPreferences
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Preferences retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListNotificationPreferencesResponse'
    put:
      tags:
        - Account
      summary: Set a notification preference
      description: |
        Upserts a single notification preference override for the authenticated user,
        scoped to a workspace and message type. Eligibility (e.g. billing types are
        owner-only) is enforced at send time, so storing an override that a hard gate
        later ignores is harmless.
      operationId: setNotificationPreference
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetNotificationPreferenceRequest'
      responses:
        '200':
          description: Preference saved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationPreferenceResponse'
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      tags:
        - Account
      summary: Reset a notification preference
      description: |
        Removes a single preference override, reverting the `(workspace, messageType)`
        back to its default. `workspaceId` and `messageType` may be supplied in the JSON
        body or as query-string parameters.
      operationId: deleteNotificationPreference
      security:
        - BearerAuth: []
      parameters:
        - name: workspaceId
          in: query
          required: false
          schema:
            type: string
        - name: messageType
          in: query
          required: false
          schema:
            type: string
      responses:
        '200':
          description: Preference reset successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /auth/billing:
    post:
      tags:
        - Account
      summary: Billing & credit management
      description: |
        Multi-action billing endpoint. The `action` field determines the operation.
        All actions require JWT authentication.

        **Actions:**

        - **get-balance** (default): Returns credit balance for the current billing month of the authenticated user.
          - If `workspaceId` is provided, returns the balance of that workspace's owner instead.
          - Triggers auto top-off via Stripe if balance < 100 credits, the owner has `topOffEnabled`, a paid tier, and a saved payment method.

        - **purchase-credits**: Creates a one-time credit purchase on the authenticated user's own account.
          - If the user has a saved payment method, charges immediately (off-session) and credits are added via webhook.
          - If not, returns a Stripe Checkout URL to complete payment.
          - A Stripe customer is created lazily if one does not already exist.

        - **modify-subscription**: Changes the authenticated user's subscription tier.
          - If the user has a payment method: upgrades/downgrades directly via API.
          - If the user needs to add a payment method (upgrade with no card on file): returns a Stripe Checkout URL.
          - Upgrades prorate immediately. Downgrades apply at the next billing cycle.

        - **manage-payment**: Returns a Stripe Customer Portal URL where the authenticated user can manage payment methods.
          - Always returns a URL (user is redirected to Stripe).

      operationId: billing
      security:
        - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/GetBalanceRequest'
                - $ref: '#/components/schemas/PurchaseCreditsRequest'
                - $ref: '#/components/schemas/ModifySubscriptionRequest'
                - $ref: '#/components/schemas/ManagePaymentRequest'
                - $ref: '#/components/schemas/CreateCustomerRequest'
      responses:
        '200':
          description: Action completed successfully
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/GetBalanceResponse'
                  - $ref: '#/components/schemas/PurchaseCreditsResponse'
                  - $ref: '#/components/schemas/ModifySubscriptionResponse'
                  - $ref: '#/components/schemas/ManagePaymentResponse'
                  - $ref: '#/components/schemas/CreateCustomerResponse'
        '404':
          description: Workspace or user not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /workspaces:
    post:
      tags:
        - Workspaces
      summary: Manage workspaces and permissions
      description: |
        Multi-action endpoint for workspace management. The `action` field determines the operation:
        
        **Actions:**
        
        - **create**: Create a new workspace owned by the authenticated user
          - Returns: `CreateWorkspaceResponse` with status 201
          - Requires: JWT token (Bearer Auth)
          - Creator automatically gets full permission
          - Returns: workspace ID, name, description, creator, creation timestamp, permissions array, and user permission
        
        - **grant-permission**: Grant or update access to a workspace for a user
          - Returns: `GrantPermissionResponse` with status 201
          - Requires: JWT token, full access to the workspace
          - Only available on paid-tier workspaces — free-tier workspaces return 403 (`UPGRADE_REQUIRED`)
          - Takes userEmail as parameter (user ID is looked up internally)
          - If user email is not registered, creates placeholder user and sends invitation email
          - If user exists but email not verified, sends unverified user invitation email
          - If user is verified, sends access granted notification email
          - If user already has access, updates their permission level

        - **revoke-permission**: Revoke access from a user for a workspace
          - Returns: `RevokePermissionResponse` with status 200
          - Requires: JWT token, full access to the workspace
          - Takes targetUserId to identify the user to revoke from
          - You cannot revoke your own permissions (returns 400)

        - **update-workspace**: Update workspace name and/or description
          - Returns: `UpdateWorkspaceResponse` with status 200
          - Requires: JWT token, full access to the workspace
          - At least one of name or description must be provided

        - **delete-workspace**: Delete a workspace
          - Returns: `DeleteWorkspaceResponse` with status 200
          - Requires: JWT token, full access to the workspace
          - Deletes the workspace and all associated permissions

        - **get-workspace**: Retrieve details for a specific workspace
          - Returns: `GetWorkspaceResponse` with status 200
          - Requires: JWT token, user must have access to the workspace
          - Returns: workspace details and a permissions array (userId, email, firstName, lastName, permission) for all users
        
        - **get-workspaces**: List all workspaces the authenticated user has access to
          - Returns: `GetWorkspacesResponse` with status 200
          - Requires: JWT token (Bearer Auth)
          - Returns: array of workspaces with permissions array for each
        
        - **list-queries**: List the workspace's query execution history
          - Returns: `ListQueriesResponse` with status 200
          - Requires: JWT token, any access (full or read-only) to the workspace
          - One row per executed query, newest first
          - Cursor-paginated: pass the returned `nextCursor` back to fetch the next page (null when exhausted)
          - Optional server-side filters: `userId`, `search` (case-insensitive substring of the query text), `minDuration` (ms), and a `startTime`/`endTime` (ISO 8601) range on execution time
          - Also returns `users` (the workspace's members) to populate a user filter

        - **list-bytes-accessed**: List the workspace's hourly bytes-accessed usage log
          - Returns: `ListBytesAccessedResponse` with status 200
          - Requires: JWT token, any access (full or read-only) to the workspace
          - One row per (hour, attributed clientId, table), newest first
          - Cursor-paginated: pass the returned `nextCursor` back to fetch the next page (null when exhausted)
          - Optional server-side filters: `clientId`, `table`, `minBytesMB`, and a `startTime`/`endTime` (ISO 8601) hour range
          - Also returns `clients` (the account's API credentials attributed in this workspace's usage, see `POST /oauth/clients`) to populate a client-label filter

        - **list-storage**: List the workspace's hourly storage usage log
          - Returns: `ListStorageResponse` with status 200
          - Requires: JWT token, any access (full or read-only) to the workspace
          - One row per (hour, table), newest first
          - Cursor-paginated: pass the returned `nextCursor` back to fetch the next page (null when exhausted)
          - Optional server-side filters: `table`, `minStorageMB`, and a `startTime`/`endTime` (ISO 8601) hour range

        - **list-processed-files**: List the workspace's processed-files log (per-file DQ)
          - Returns: `ListProcessedFilesResponse` with status 200
          - Requires: JWT token, **full access** to the workspace (owner or full member)
          - One row per input file processed by any data-load run (manual or trigger), newest first, with per-file `dqChecks` (one result per data-quality check, each `pass`/`fail`/`not_applicable`; `dqIssues`/`dqFailCount`/`dqSchemaVersion` also returned — `dqSchemaVersion` is absent on rows written before per-check tracking, in which case only the legacy failures-only `dqIssues` is populated)
          - Cursor-paginated: pass the returned `nextCursor` back to fetch the next page (null when exhausted)
          - Optional server-side filters: `specId`, `status`, `fileName` (case-sensitive substring), `dqOnly` (only files with ≥1 DQ issue), and a `startTime`/`endTime` (ISO 8601) range on when the file was processed

        - **list-trigger-runs**: List the workspace's trigger execution history, across all triggers
          - Returns: `ListTriggerRunsResponse` with status 200
          - Requires: JWT token, **full access** to the workspace (owner or full member)
          - One row per trigger execution (see `JobTriggerRun`), newest first — the workspace-wide
            counterpart to a single trigger; a run created by `run-trigger-now` (on `/job-triggers`)
            appears here, `status: running` until the worker finishes it
          - Cursor-paginated: pass the returned `nextCursor` back to fetch the next page (null when exhausted)
          - Optional server-side filters: `specId`, `connectionId`, and a `startTime`/`endTime` (ISO 8601) range on when the run started
      operationId: manageWorkspaces
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/CreateWorkspaceRequest'
                - $ref: '#/components/schemas/GrantPermissionRequest'
                - $ref: '#/components/schemas/RevokePermissionRequest'
                - $ref: '#/components/schemas/UpdateWorkspaceRequest'
                - $ref: '#/components/schemas/DeleteWorkspaceRequest'
                - $ref: '#/components/schemas/GetWorkspaceActionRequest'
                - $ref: '#/components/schemas/GetWorkspacesActionRequest'
                - $ref: '#/components/schemas/ListQueriesRequest'
                - $ref: '#/components/schemas/ListBytesAccessedRequest'
                - $ref: '#/components/schemas/ListStorageRequest'
                - $ref: '#/components/schemas/ListProcessedFilesRequest'
                - $ref: '#/components/schemas/ListTriggerRunsRequest'
            examples:
              create:
                summary: Create workspace
                value:
                  action: create
                  name: My Data Processing Workspace
                  description: A workspace for processing customer data
              grant:
                summary: Grant permission
                value:
                  action: grant-permission
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  userEmail: newuser@example.com
                  permission: read-only
              revoke:
                summary: Revoke permission
                value:
                  action: revoke-permission
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  targetUserId: 550e8400-e29b-41d4-a716-446655440002
              update:
                summary: Update workspace
                value:
                  action: update-workspace
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  name: Updated Workspace Name
                  description: Updated description
              delete:
                summary: Delete workspace
                value:
                  action: delete-workspace
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
              getWorkspace:
                summary: Get workspace details
                value:
                  action: get-workspace
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
              getWorkspaces:
                summary: List all user workspaces
                value:
                  action: get-workspaces
              listQueries:
                summary: List query history (filtered, paginated)
                value:
                  action: list-queries
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  pageSize: 25
                  search: select
                  minDuration: 1000
                  startTime: '2026-06-01T00:00:00Z'
                  endTime: '2026-06-26T00:00:00Z'
              listBytesAccessed:
                summary: List bytes-accessed usage (filtered, paginated)
                value:
                  action: list-bytes-accessed
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  pageSize: 25
                  minBytesMB: 100
                  startTime: '2026-06-01T00:00:00Z'
                  endTime: '2026-06-26T00:00:00Z'
              listStorage:
                summary: List storage usage (filtered, paginated)
                value:
                  action: list-storage
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  pageSize: 25
                  minStorageMB: 100
                  startTime: '2026-06-01T00:00:00Z'
                  endTime: '2026-06-26T00:00:00Z'
              listProcessedFiles:
                summary: List processed files with per-file DQ (filtered, paginated)
                value:
                  action: list-processed-files
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  pageSize: 25
                  dqOnly: true
                  startTime: '2026-06-01T00:00:00Z'
                  endTime: '2026-06-26T00:00:00Z'
              listTriggerRuns:
                summary: List trigger runs across the workspace (filtered, paginated)
                value:
                  action: list-trigger-runs
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  pageSize: 25
                  startTime: '2026-06-01T00:00:00Z'
                  endTime: '2026-06-26T00:00:00Z'
      responses:
        '201':
          description: Workspace created or permission granted successfully
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/CreateWorkspaceResponse'
                  - $ref: '#/components/schemas/GrantPermissionResponse'
        '200':
          description: Operation successful (workspace retrieved, permissions revoked, or workspaces listed)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/GetWorkspaceResponse'
                  - $ref: '#/components/schemas/GetWorkspacesResponse'
                  - $ref: '#/components/schemas/RevokePermissionResponse'
                  - $ref: '#/components/schemas/ListQueriesResponse'
                  - $ref: '#/components/schemas/ListBytesAccessedResponse'
                  - $ref: '#/components/schemas/ListTriggerRunsResponse'
        '404':
          description: Resource not found (workspace or user)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /connections:
    post:
      tags:
        - Connections
      summary: Manage workspace external connections (SFTP, AWS S3)
      description: |
        Multi-action endpoint for workspace connections. The `action` field determines the operation.
        Supported connection `type`s are `sftp` and `aws_s3`.

        For SFTP, DPF generates an RSA-4096 keypair when the connection is created. The **public key**
        is returned so it can be installed in the remote server's `authorized_keys`; the **private key**
        is retained server-side and is **never** returned by any action.

        For `aws_s3`, DPF generates an **ExternalId** when the connection is created and returns a ready-to-paste
        **trust policy** (naming DPF's dedicated connector role as principal, gated by that ExternalId) along with
        `dpfPrincipalArn`. The customer creates (or updates) an IAM role in their own AWS account with that trust
        policy and a permissions policy of their choosing, granting exactly the S3 access DPF needs. No credential
        is ever stored — DPF assumes the customer's role on demand via `sts:AssumeRole`.

        Every connection tracks `lastTestStatus`/`lastTestedAt` from its most recent `test-connection` call. A
        connection must have `lastTestStatus: passed` before it can be used to create or run a trigger (see
        `/job-triggers`) — this also self-heals: if a scheduled run's connect/assume-role step fails, the
        connection is flipped back to `failed`, blocking further use until it's re-tested.

        **Actions:**

        - **create-connection**: Create a connection. For `sftp`, generates the keypair and returns the public
          key. For `aws_s3`, generates the ExternalId and returns the trust policy.
          - Returns: `Connection` (with `publicKey` for sftp, or `externalId`/`dpfPrincipalArn`/`trustPolicy` for
            aws_s3 — never `privateKey`) with status 201
          - Requires: JWT token, full access to the workspace
          - Fields (sftp): `workspaceId`, `type: sftp`, `hostname`, optional `username` (defaults to `sftpuser`)
          - Fields (aws_s3): `workspaceId`, `type: aws_s3`, `roleArn` (the IAM role the customer will create/update)

        - **get-connections**: List all connections for a workspace.
          - Returns: `GetConnectionsResponse` with status 200 (each item includes `publicKey`, never `privateKey`)
          - Requires: JWT token, any access (full or read-only) to the workspace

        - **test-connection**: Verify a connection works. For SFTP, connects with the stored private key
          and lists the home directory. For `aws_s3`, assumes the customer's role (via DPF's connector role
          and the stored ExternalId) and calls `GetCallerIdentity`. Success means authentication worked (an
          empty SFTP directory listing still counts as success). Also updates `lastTestStatus`/`lastTestedAt`.
          - Returns: `TestConnectionResponse` with status 200. A reachable-but-failed test returns
            `success: false` with a reason (not an HTTP error).
          - Requires: JWT token, any access (full or read-only) to the workspace

        - **delete-connection**: Delete a connection (and its stored keypair, for sftp). Idempotent.
          - Returns: `DeleteConnectionResponse` with status 200
          - Requires: JWT token, full access to the workspace
      operationId: manageConnections
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/CreateConnectionRequest'
                - $ref: '#/components/schemas/GetConnectionsRequest'
                - $ref: '#/components/schemas/TestConnectionRequest'
                - $ref: '#/components/schemas/DeleteConnectionRequest'
            examples:
              createConnection:
                summary: Create an SFTP connection
                value:
                  action: create-connection
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  type: sftp
                  hostname: sftp.example.com
                  username: sftpuser
              createAwsS3Connection:
                summary: Create an aws_s3 connection
                value:
                  action: create-connection
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  type: aws_s3
                  roleArn: arn:aws:iam::123456789012:role/dpf-ingestion
              getConnections:
                summary: List connections
                value:
                  action: get-connections
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
              testConnection:
                summary: Test a connection
                value:
                  action: test-connection
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  connectionId: 7b3f8a10-1c2d-4e5f-9a0b-1c2d3e4f5a6b
              deleteConnection:
                summary: Delete a connection
                value:
                  action: delete-connection
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  connectionId: 7b3f8a10-1c2d-4e5f-9a0b-1c2d3e4f5a6b
      responses:
        '201':
          description: Connection created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateConnectionResponse'
        '200':
          description: Connections listed, connection tested, or connection deleted
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/GetConnectionsResponse'
                  - $ref: '#/components/schemas/TestConnectionResponse'
                  - $ref: '#/components/schemas/DeleteConnectionResponse'
        '403':
          description: No access, or read-only access attempting a mutating action
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Connection not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /job-triggers:
    post:
      tags:
        - Job Triggers
      summary: Manage workspace ingestion triggers (SFTP, AWS S3, spec chaining)
      description: |
        Multi-action endpoint for workspace job triggers. The `action` field determines the operation.
        Supported trigger `type`s are `sftp`, `aws_s3`, `spec_success`, and `schedule`; for `sftp`/`aws_s3`
        the type must match its connection's type. For `aws_s3`, `s3Bucket` is required (the bucket to
        poll) and `s3Prefix` is optional (defaults to the whole bucket).

        **`aws_s3` listing cap:** each run lists at most 5000 objects from the bucket/prefix, oldest key
        first, and stops there. Once a bucket/prefix holds more than 5000 objects, newly delivered files
        can fall outside that window and never be seen by the trigger — it will report no new files even
        though delivery is working. Mitigate by attaching an S3 lifecycle rule that expires or transitions
        older objects out of the prefix so it stays under the cap. `create-trigger` returns a `warnings`
        array with this same note whenever `type: aws_s3`.

        A trigger references a spec (by `specName`) — the **downstream** spec it fires. For `sftp`/
        `aws_s3` it also references a connection (by `connectionId`); the natural-language `preRules`
        (which files to process) and `postRules` (what to do after upload) are converted by the backend
        into **executable JavaScript** (`preCode` / `postCode`) that is returned and shown read-only.
        Behavior is changed by editing the rules — the code is regenerated; raw code is never accepted
        from the client.

        A separate `dedupe` boolean enables per-spec "don't load the same file twice": files already
        loaded into the spec by a previous run (matched by file name) are skipped. Not applicable to
        `spec_success`.

        An hourly dispatcher runs enabled `sftp`/`aws_s3` triggers according to `frequency`
        (`hourly`, `daily` + `hourOfDay`, or `monthly` + `dayOfMonth` + `hourOfDay`; all UTC). The
        referenced spec must already have been analyzed once (it must have transformation code) —
        a trigger feeds an analyzed spec, it does not bootstrap one.

        `spec_success` triggers fire reactively instead: when the spec named by `upstreamSpecId`/
        `upstreamSpecName` completes a successful job, its downstream spec (`specName`) is run
        automatically — no `frequency`/`connectionId`. Chains that would create a cycle (directly or
        through other `spec_success` triggers) are rejected with 409 `CYCLE_DETECTED`.

        `schedule` triggers fire on a plain cron-like `frequency` like sftp/aws_s3, but with no
        connection — the spec's job is fired directly rather than pulling files. Use this for a
        table-source spec that should re-run periodically rather than (or in addition to) being
        chained off an upstream spec's success.

        Both `spec_success` and `schedule` downstream specs (`specName`) must be table-source
        (`sourceType: 'tables'`) specs — each run re-executes the spec's generated SQL query, windowed
        to rows since its last successful run, so there is no file for either trigger type to load.

        **Actions:**

        - **create-trigger**: Validate the spec (+ connection, for sftp/aws_s3), generate `preCode`/
          `postCode` from the rules (sftp/aws_s3 only), persist, and return the trigger.
          - Requires: JWT token, full access to the workspace
          - Fields: `workspaceId`, `type` (`sftp`, `aws_s3`, `spec_success`, or `schedule`), `specName`;
            for sftp/aws_s3: `connectionId`, `frequency`, optional `preRules`, `postRules`, `dedupe`;
            `s3Bucket` (required)/`s3Prefix` (optional) when `type: aws_s3`; for spec_success:
            `upstreamSpecName`; for schedule: `frequency`; `enabled` optional for all types.
          - For sftp/aws_s3, the connection's most recent `lastTestStatus` must be `passed` (see the
            `Connection` schema) — returns 400 `CONNECTION_NOT_TESTED` otherwise.
          - Returns: the created trigger (with `preCode`/`postCode` for sftp/aws_s3) with status 201.
            For `type: aws_s3`, the response also includes a `warnings` array noting the 5000-object
            listing cap described above.

        - **get-triggers**: List all triggers for a workspace (each includes `preCode`/`postCode`).
          - Requires: JWT token, any access to the workspace

        - **update-trigger**: Update `preRules`/`postRules`/`dedupe`/`frequency`/`enabled`. Changing any
          rule or `dedupe` regenerates the code. Never accepts raw code.
          - Requires: JWT token, full access to the workspace

        - **delete-trigger**: Delete a trigger. Idempotent.
          - Requires: JWT token, full access to the workspace

        - **run-trigger-now**: Asynchronously fire the trigger's worker immediately. Returns 202. The
          run this creates (and its status) shows up in the workspace-wide trigger-runs log — see
          `list-trigger-runs` on `/workspaces`.
          - Requires: JWT token, full access to the workspace
          - The trigger's connection must currently have `lastTestStatus: passed` — returns 400
            `CONNECTION_NOT_TESTED` otherwise. The same check applies inside the worker for scheduled
            (hourly dispatcher) runs, so a connection that stops passing is blocked either way. If a
            run's SFTP connect fails at runtime (e.g. the customer revoked the key since it last passed),
            the connection's `lastTestStatus` is flipped to `failed`, which then blocks future
            create-trigger/run-trigger-now calls until it's re-tested and passes again.

        - **clear-processed-files**: Clear the per-spec processed-file ledger used for dedup, so the next
          run re-processes files that were loaded before ("start over"). Data already loaded into the
          spec's table is not affected. Returns the number of ledger records deleted.
          - Requires: JWT token, full access to the workspace
      operationId: manageJobTriggers
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/CreateTriggerRequest'
                - $ref: '#/components/schemas/GetTriggersRequest'
                - $ref: '#/components/schemas/UpdateTriggerRequest'
                - $ref: '#/components/schemas/DeleteTriggerRequest'
                - $ref: '#/components/schemas/RunTriggerNowRequest'
                - $ref: '#/components/schemas/ClearProcessedFilesRequest'
            examples:
              createTrigger:
                summary: Create an SFTP trigger
                value:
                  action: create-trigger
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  type: sftp
                  specName: web-logs
                  connectionId: 7b3f8a10-1c2d-4e5f-9a0b-1c2d3e4f5a6b
                  frequency:
                    unit: daily
                    hourOfDay: 6
                  dedupe: true
                  preRules: "Process only *.csv files under /outbound"
                  postRules: "Rename each processed file with a .done suffix"
              createAwsS3Trigger:
                summary: Create an aws_s3 trigger
                value:
                  action: create-trigger
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  type: aws_s3
                  specName: web-logs
                  connectionId: 7b3f8a10-1c2d-4e5f-9a0b-1c2d3e4f5a6b
                  s3Bucket: customer-data-bucket
                  s3Prefix: exports/daily/
                  frequency:
                    unit: daily
                    hourOfDay: 6
                  dedupe: true
              createSpecSuccessTrigger:
                summary: Chain a table-source spec off another spec's success
                value:
                  action: create-trigger
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  type: spec_success
                  specName: daily-signups-aggregate
                  upstreamSpecName: daily-signups-load
              createScheduleTrigger:
                summary: Run a table-source spec on a plain schedule (no upstream spec)
                value:
                  action: create-trigger
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  type: schedule
                  specName: daily-signups-aggregate
                  frequency:
                    unit: daily
                    hourOfDay: 7
              getTriggers:
                summary: List triggers
                value:
                  action: get-triggers
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
              runTriggerNow:
                summary: Fire a trigger now
                value:
                  action: run-trigger-now
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  triggerId: 9c1e2d3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f
      responses:
        '200':
          description: Triggers listed, updated, deleted, or run history returned
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/GetTriggersResponse'
                  - $ref: '#/components/schemas/UpdateTriggerResponse'
                  - $ref: '#/components/schemas/DeleteTriggerResponse'
                  - $ref: '#/components/schemas/ClearProcessedFilesResponse'
        '201':
          description: Trigger created (includes generated preCode/postCode)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateTriggerResponse'
        '202':
          description: Trigger run started
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RunTriggerNowResponse'
        '403':
          description: No access, or read-only access attempting a mutating action
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Trigger, spec, or connection not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /data-specs:
    post:
      tags:
        - Data Specs
      summary: Manage data transformation pipelines
      description: |
        Multi-action endpoint for data spec management and data transformation pipeline orchestration. The `action` field determines the operation.
        
        **Authentication**: All actions require a valid JWT token in the Authorization header. The `userId` is automatically extracted from the token.
        
        ## Workflow Overview
        
        There are two data flows for running transformation jobs:
        
        ---
        
        ### Flow A: Create Spec (First-Time Setup)
        
        Use this flow when setting up a new data transformation for the first time. It infers the schema and generates transformation code using AI.
        
        **Step 1: Create Spec** (`create-spec`)
        - Creates a data spec record (no job yet)
        - Returns presigned upload URLs for the sample/format/schema files, which land in the
          canonical spec folder `s3://dpf-specs/<workspaceId>/<specId>/` and can be replaced later
        - Supports three target options:
          - **existing-tables**: Use tables already in the Iceberg catalog
          - **target-schema-file**: Provide a target schema file
          - **auto-infer**: Let AI infer the target schema from sample data
        - Status: **201 Created**

        **Step 2: Upload Files**
        - Use the presigned URLs from Step 1 to upload your files into the spec folder
        - Required: sample file (the actual data to load)
        - Optional: format specification file (describes layout/structure)
        - Optional: target schema file (if targetOption is target-schema-file)
        - ⚠️ Sample, format, and target schema files must be **100 KB or smaller** (AI prompt size constraint)
        - ⚠️ URLs expire in 1 hour

        **Step 3: Start Analysis** (`start-analysis`)
        - Invokes the prepare-transformation pipeline asynchronously:
          1. Reads sample and format files
          2. Infers/determines target schema using AI
          3. Injects DPF audit fields into schema
          4. Generates parseInputFilesCode and transformToSchemaCode
          5. Stores transformation config in the database
          6. Creates the data-load job run (`jobId`), copies the spec's sample file into the job folder `s3://dpf-jobs/<workspaceId>/<specId>/<jobId>/`, and starts the data-load Glue job
        - Returns immediately for polling (status 202). No `jobId` is returned: the job run is
          only created once preparation succeeds (step 6), so a preparation failure — e.g. a
          target table-name conflict — never leaves a job record behind.

        **Step 4: Poll for Results** (`get-status`)
        - Poll with `specId` to track the pipeline
        - Status goes `processing` → `ready` (or `failed`); `ready` means preparation succeeded
          and the data load has been handed to the Glue job (its run appears as `lastJobId`)
        
        ---
        
        ### Flow B: Create Job (Re-Run with New Files)
        
        Use this flow after a spec already has transformation code (from Flow A). Load new data files using the same parsing/transformation logic without re-invoking AI.
        
        **Step 1: Create Job** (`create-job`)
        - Creates a new job record for an existing spec
        - You provide a list of filenames to process
        - Generates `manifest.txt` in the job folder `s3://dpf-jobs/<workspaceId>/<specId>/<jobId>/` and returns presigned upload URLs for each file
        - Status: **201 Created**
        
        **Step 2: Upload Files**
        - Use the presigned URLs from Step 1 to upload your data files into the job folder
        - These are the actual production data files (not limited to 100 KB)
        - ⚠️ URLs expire in 1 hour
        
        **Step 3: Start Job** (`start-job`)
        - Triggers the Glue data-load job directly (no AI step)
        - Requires: the spec must already have `transformationConfig` (from a prior `start-analysis`)
        - Status: **202 Accepted**
        
        **Step 4: Poll for Results** (`get-status`)
        - Poll with `jobId` to track the data-load run
        - On completion: includes metrics (recordsRead, recordsWritten, filesProcessed) and snapshot info

        ---

        ### Flow C: Table-Source Spec (Query Existing Tables, No Files)

        Use this flow to derive/aggregate data already in your Iceberg tables — e.g. an
        incrementally-updated summary table — instead of loading a file. Set `sourceType: 'tables'`
        on `create-spec`.

        **Step 1: Create Spec** (`create-spec`)
        - `sourceType: 'tables'`, `sourceTables` (source table name(s)), `targetTables` with
          exactly **one** target table (regardless of `targetOption` — see Flow A/B above for
          what each option means; all three work here too). No sample/format files ever; a
          `target-schema-file` upload is the only possible presigned URL.
        - `additionalPrompt` (optional) tells the AI what the query should compute; `merge`
          selects `MERGE` (upsert) vs `INSERT` (append) for the generated SQL.

        **Step 2: Start Analysis** (`start-analysis`)
        - Fetches the source table schema(s) via the Iceberg REST catalog. For the target:
          `existing-tables` fetches its schema; `target-schema-file` reads the uploaded file and
          creates the table from it; `auto-infer` has the AI design the schema (together with the
          query, in one pass) and creates the table — same table-creation path Flow A uses.
        - Generates a two-part SQL statement: an AI-generated `sourceQuerySql` (becomes `WITH
          source AS (...)`) and a deterministic `targetLoadSql` (`INSERT`/`MERGE` reading `FROM
          source`). Both are stored on the spec and always visible via `get-status`/`list-specs`
          — there is no approval gate, same as the file flow's generated Python being view-only.
        - Unless `loadSampleData: false`, creates a job run and async-invokes the query engine
          directly (no Glue job). The query is automatically windowed to rows since the spec's
          last successful run, tracked as `lastRunTs` (a spec with none yet — e.g. its first run —
          is windowed from a fixed early sentinel, effectively "everything"). See `update-spec`'s
          `lastRunTs` to override this and force a reprocess from an earlier point in time.
        - Status: **202 Accepted**

        **Step 3: Poll for Results** (`get-status`)
        - Same as Flow A/B — poll by `specId` then `jobId`.

        A **`spec_success`** trigger (see `/job-triggers`) can fire a table-source spec's job
        automatically whenever another spec's job completes successfully — the natural way to
        re-run an aggregate after its upstream load finishes.

        **Ad-hoc re-run**: Flow B's `create-job` + `start-job` work here too — omit `files` on
        `create-job` (there's nothing to upload) to get a bare job record, then `start-job` runs
        the spec's already-generated query again. To reprocess from an earlier point, call
        `update-spec` with a past `lastRunTs` first.

        ---

        ## File Size Constraints
        
        | File Type | Max Size | Reason |
        |-----------|----------|--------|
        | Sample file | 100 KB | Sent to AI for schema inference and code generation |
        | Format file | 100 KB | Sent to AI for parsing rule determination |
        | Target schema file | 100 KB | Sent to AI to derive the target tables and field mapping |
        | Data files (create-job) | No limit | Processed by Glue job, not sent to AI |
        
        Files exceeding the 100 KB limit will cause the pipeline to fail with error code `FILE_TOO_LARGE`.
        Provide a representative sample of your data (first 50-100 rows is sufficient).
        
        ## DPF Audit Fields
        
        Every target table automatically includes 4 system-generated audit columns appended to the schema:
        
        | Field | Type | Description |
        |-------|------|-------------|
        | dpf_line | long | 1-based row number in the source file |
        | dpf_filename | string | Original filename provided by the user |
        | dpf_job | string | The jobId for this data load run |
        | dpf_ts | timestamp | Timestamp captured before the table append |
        
        These fields are injected automatically and do not need to be included in target schemas.
        
        ## Important Notes
        
        - ⚠️ **File Upload Required**: Files MUST be uploaded before calling `start-analysis` or `start-job`.
        - ⚠️ **100 KB Limit**: Only applies to the spec files in Flow A — sample, format, and target schema (not data files in Flow B).
        - ⚠️ **Asynchronous Processing**: Pipeline runs in background. Poll with `get-status`.
        - ⚠️ **Spec Name is Unique**: `specName` must be unique per workspace.
        - ⚠️ **Prompt Caching**: Identical AI prompts are cached to avoid redundant AI calls.
        - ⚠️ **Iceberg Snapshots**: Created automatically during data-load. Use the Iceberg REST Catalog interface for time-travel and rollback.
        
        ## Actions
        
        - **create-spec** - Create spec. For sourceType=file (default), get presigned upload URLs for the spec folder (Flow A). For sourceType=tables, no files — the spec is ready for start-analysis immediately (Flow C).
        - **update-spec** - Update an existing spec's mutable fields (target, merge, computeSize, additionalPrompt, description, and sourceType-specific fields), then call start-analysis separately to regenerate code/SQL and (optionally) re-run. For sourceType=tables, also accepts `lastRunTs` to override the incremental watermark table_job.py uses as its SQL's lower bound — set it to a past timestamp to force the next run to reprocess data from that point on.
        - **create-job** - Create a new job for an existing spec (Flow B). For sourceType=file, pass a custom `files` list and get back signed upload URLs. For sourceType=tables, omit `files` — there's nothing to upload, so this just creates a bare job record ready for start-job.
        - **start-analysis** - Start async pipeline. sourceType=file: infer schema, generate code, then (by default) load the sample and run the data-load job (Flow A). sourceType=tables: generate the SQL query, then (by default) run it via the query engine (Flow C). Pass `loadSampleData: false` to stop after code/query generation without running anything. Idempotent while a prior run is still in progress: a repeat call within that run's staleness window returns the current status instead of starting a second pipeline run — poll with `get-status`/`specId` instead of calling this again just to check progress.
        - **start-job** - Trigger the job's execution engine directly (Flow B) — the Glue data-load job for sourceType=file, or a re-run of the generated query for sourceType=tables. Idempotent while the job is already running: a repeat call within its staleness window returns the current status instead of starting a second run — poll with `get-status`/`jobId` instead of calling this again just to check progress.
        - **get-status** - Check progress and retrieve results. Poll with `jobId` for a specific data-load job run, or with `specId` for the spec's own process (schema inference + code generation), read directly off the spec record
        - **delete-spec** - Delete a spec and its stored files; job records and other usage logs are retained (aging out via TTL) so credit-consumption history stays visible
        - **list-specs** - List the workspace's specs, in `specName` order. Cursor-paginated: pass `pageSize` (default 25, max 100) and the returned `nextCursor` back to fetch the next page (`null` when exhausted)
        - **list-jobs** - List the workspace's jobs, newest first. Cursor-paginated: pass `pageSize` (default 25, max 100) and the returned `nextCursor` back to fetch the next page (`null` when exhausted). Optionally filter by `specId`, `status` (ready/processing/complete/failed), and/or a `startTime`/`endTime` (ISO 8601) range on `createdAt`

      operationId: manageDataSpecs
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/CreateSpecRequest'
                - $ref: '#/components/schemas/UpdateSpecRequest'
                - $ref: '#/components/schemas/CreateJobRequest'
                - $ref: '#/components/schemas/StartJobRequest'
                - $ref: '#/components/schemas/StartAnalysisRequest'
                - $ref: '#/components/schemas/GetStatusRequest'
                - $ref: '#/components/schemas/DeleteSpecRequest'
            examples:
              createSpecAutoInfer:
                summary: 'Flow A, Step 1: Create spec (auto-infer schema from sample)'
                value:
                  action: create-spec
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: Customer Data Load
                  sampleFileName: customers.csv
                  formatFileName: schema.json
                  targetOption: auto-infer
                  computeSize: small
                  merge: false
                  additionalPrompt: "Parse the header row as column names"
                  description: Auto-detect schema from sample file with provided format
              createSpecExistingTables:
                summary: 'Flow A, Step 1: Create spec (existing tables)'
                value:
                  action: create-spec
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: Customer Data Load
                  sampleFileName: customers.csv
                  formatFileName: schema.json
                  targetOption: existing-tables
                  targetTables: [customers, accounts]
                  computeSize: large
                  merge: true
                  description: Load customer data into existing tables
              createSpecTableSource:
                summary: 'Flow C, Step 1: Create a table-source (query) spec into an existing table'
                value:
                  action: create-spec
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: daily-signups-aggregate
                  sourceType: tables
                  sourceTables: [customers]
                  targetOption: existing-tables
                  targetTables: [customer_signups_daily]
                  merge: true
                  additionalPrompt: "Count signups per day per region from the customers table"
                  description: Incrementally aggregate daily signups into a summary table
              createSpecTableSourceAutoInfer:
                summary: 'Flow C, Step 1: Table-source spec where the AI also designs the target table'
                value:
                  action: create-spec
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: daily-signups-aggregate
                  sourceType: tables
                  sourceTables: [customers]
                  targetOption: auto-infer
                  merge: true
                  additionalPrompt: "Count signups per day per region from the customers table"
                  description: Same as above, but the target table does not exist yet — AI designs its schema, picks its name, and creates it (targetTables omitted; pass it to pin the name yourself instead)
              updateSpec:
                summary: 'Update a spec''s settings (call start-analysis afterward to regenerate/re-run)'
                value:
                  action: update-spec
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: Customer Data Load
                  computeSize: large
                  additionalPrompt: "Also dedupe on customer_id"
                  changedFiles: []
              updateSpecWatermark:
                summary: 'Update a table-source spec''s watermark to force a reprocess from a past date'
                value:
                  action: update-spec
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: daily-signups-aggregate
                  lastRunTs: '2026-01-01T00:00:00.000Z'
                  changedFiles: []
              startAnalysis:
                summary: 'Flow A, Step 3: Start analysis (infer schema + generate code + run)'
                value:
                  action: start-analysis
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: Customer Data Load
              startAnalysisNoLoad:
                summary: 'Flow A, Step 3 (alt): Start analysis without loading the sample'
                value:
                  action: start-analysis
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: Customer Data Load
                  loadSampleData: false
              createJob:
                summary: 'Flow B, Step 1: Create job (new files for existing spec)'
                value:
                  action: create-job
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: Customer Data Load
                  files: [batch_001.csv, batch_002.csv, batch_003.csv]
              startJob:
                summary: 'Flow B, Step 3: Start job (triggers Glue data-load directly)'
                value:
                  action: start-job
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: Customer Data Load
                  jobId: 550e8400-e29b-41d4-a716-446655440006
              createJobTableSource:
                summary: 'Flow C: Ad-hoc re-run, Step 1 — create a bare job record (no files to upload)'
                value:
                  action: create-job
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: daily-signups-aggregate
              startJobTableSource:
                summary: 'Flow C: Ad-hoc re-run, Step 2 — runs the already-generated query again'
                value:
                  action: start-job
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: daily-signups-aggregate
                  jobId: 550e8400-e29b-41d4-a716-446655440007
              getStatusByJobId:
                summary: 'Both Flows, Step 4: Poll for results by jobId'
                value:
                  action: get-status
                  jobId: 550e8400-e29b-41d4-a716-446655440005
              getStatusBySpecId:
                summary: 'Both Flows, Step 4 (alt): Poll a spec''s own process by specId'
                value:
                  action: get-status
                  specId: 550e8400-e29b-41d4-a716-446655440003
              deleteSpec:
                summary: Delete a spec (stored files removed; job history retained)
                value:
                  action: delete-spec
                  workspaceId: 550e8400-e29b-41d4-a716-446655440000
                  specName: Customer Data Load
                  deleteTargetTables: false
      responses:
        '201':
          description: 'Spec or job created successfully. Use signed URLs to upload files (max 100 KB for sample/format/target-schema).'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/CreateSpecResponse'
                  - $ref: '#/components/schemas/CreateJobResponse'
        '200':
          description: |
            Success response for multiple actions:
            - **update-spec**: Spec updated successfully. Call start-analysis afterward to regenerate code/SQL and (optionally) re-run.
            - **get-status**: Status retrieved successfully
            - **delete-spec**: Spec deleted successfully (job history retained in the job log)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/UpdateSpecResponse'
                  - $ref: '#/components/schemas/GetStatusByJobIdResponse'
                  - $ref: '#/components/schemas/GetStatusBySpecIdResponse'
                  - $ref: '#/components/schemas/DeleteSpecResponse'
        '202':
          description: 'Data transformation pipeline or job started. Poll start-analysis runs with specId (the data-load jobId appears as lastJobId once the load starts); poll start-job runs with jobId.'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/StartAnalysisResponse'
                  - $ref: '#/components/schemas/StartJobResponse'
        '404':
          description: |
            Spec, job, or table not found. Error codes:
            - `SPEC_NOT_FOUND`: Data spec not found in workspace
            - `JOB_NOT_FOUND`: Job ID does not exist
            - `TABLE_NOT_FOUND`: Target table does not exist in the workspace's namespace (create-spec/update-spec with merge and targetOption existing-tables)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Spec name already exists in workspace (error code SPEC_NAME_EXISTS)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: |
            Server error. Error codes:
            - `INTERNAL_SERVER_ERROR`: Unexpected error
            - `SCHEMA_INFERENCE_FAILED`: AI schema inference failed
            - `PARSE_CODE_GENERATION_FAILED`: AI code generation failed
            - `TRANSFORM_CODE_GENERATION_FAILED`: AI transform code generation failed
            - `DATA_LOAD_INVOCATION_ERROR`: Failed to start data load
            - `CASCADE_DELETION_FAILED`: Cascade deletion encountered errors
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /iceberg/v1/config:
    get:
      tags:
        - Iceberg REST Catalog
      summary: Iceberg REST Catalog Proxy
      description: |
        **Authenticated proxy to the Apache Iceberg REST Catalog API.**
        
        This endpoint provides standard Iceberg REST Catalog access with JWT-based authentication
        and workspace-scoped namespace authorization. Clients send standard Iceberg REST requests
        to `https://api.dpf-it.com/iceberg/v1/...` and the proxy handles authentication and signing to the Iceberg REST endpoint.
        
        ## Base URL
        
        ```
        https://api.dpf-it.com/iceberg
        ```
        
        All Iceberg REST paths are relative to this base (e.g., `/v1/config`, `/v1/namespaces`, etc.).
        
        ## Authentication
        
        Two authentication methods are supported:
        
        1. **JWT Bearer Token** (for interactive users):
           Obtain a token via `/auth/login` and pass it as `Authorization: Bearer <token>`.
        
        2. **OAuth2 Client Credentials** (for machine-to-machine access):
           Generate a credential via `POST /oauth/clients`, then exchange it for a Bearer token via
           `POST /oauth/token` with `grant_type=client_credentials`. The resulting token works across
           every workspace the account has access to (not just one). Used by catalog federation,
           Spark, Trino, and other Iceberg REST clients.
        
        ## Authorization Model
        
        Access is controlled at the **namespace** level based on workspace permissions:
        
        - Each workspace maps to a database/namespace (derived from workspaceId)
        - Users can only access namespaces for workspaces they have permissions on
        - **read-only** permission: GET and HEAD operations only
        - **full** permission: All operations (GET, HEAD, POST, PUT, DELETE)
        - `ListNamespaces` responses are filtered to only show authorized namespaces
        
        ## Catalog Configuration (from `/v1/config`)
        
        The catalog is configured with the following defaults:
        
        | Property | Value | Description |
        |----------|-------|-------------|
        | `io-impl` | `org.apache.iceberg.aws.s3.S3FileIO` | Object-storage-based file I/O |
        | `write.object-storage.enabled` | `true` | Object storage write mode |
        | `write.object-storage.partitioned-paths` | `false` | Flat path layout |
        | `s3.delete-enabled` | `false` | Object deletion disabled |
        | `rest-metrics-reporting-enabled` | `false` | Metrics reporting off |
        
        ## Supported Operations
        
        | Operation | Method & Path | Description |
        |-----------|---------------|-------------|
        | GetConfig | `GET /v1/config` | Get catalog configuration |
        | ListNamespaces | `GET /v1/namespaces` | List namespaces (filtered by access) |
        | LoadNamespaceMetadata | `GET /v1/namespaces/{ns}` | Get namespace properties |
        | NamespaceExists | `HEAD /v1/namespaces/{ns}` | Check namespace exists (returns 204) |
        | ListTables | `GET /v1/namespaces/{ns}/tables` | List tables in namespace |
        | CreateTable | `POST /v1/namespaces/{ns}/tables` | Create a table |
        | LoadTable | `GET /v1/namespaces/{ns}/tables/{t}` | Load table metadata |
        | UpdateTable | `POST /v1/namespaces/{ns}/tables/{t}` | Commit table updates |
        | TableExists | `HEAD /v1/namespaces/{ns}/tables/{t}` | Check table exists (returns 204) |
        | DropTable | `DELETE /v1/namespaces/{ns}/tables/{t}` | Delete a table |
        | RenameTable | `POST /v1/tables/rename` | Rename a table |
        
        ## Blocked Operations (use Workspace APIs instead)
        
        | Operation | Returns | Use Instead |
        |-----------|---------|-------------|
        | CreateNamespace | 405 | `POST /workspaces` with action `create` |
        | DropNamespace | 405 | `POST /workspaces` with action `delete-workspace` |
        
        Namespaces are managed through workspace lifecycle. Creating a workspace provisions its namespace;
        deleting a workspace removes it.
        
        ## Not Supported (Current Limitations)
        
        - View operations (createView, loadView, dropView, listViews, renameView)
        - Multi-level namespaces (only single-level supported)
        - `stage-create` option on CreateTable (no CTAS support)
        - Namespace properties other than `owner`
        - REST metrics reporting
        
        ## Limitations
        
        - Table `metadata.json` files over 50 MB are not supported (use table maintenance)
        - Object deletion is disabled (`s3.delete-enabled=false`)
        - Snapshot expiry + storage cleanup are managed automatically (see "Snapshot Retention & Storage Cleanup" below); compaction can be performed via PyIceberg or the catalog's table optimizer
        
        ## Client Configuration Example (PyIceberg)
        
        ```python
        from pyiceberg.catalog import load_catalog
        
        catalog = load_catalog(
            "dpf",
            **{
                "type": "rest",
                "uri": "https://api.dpf-it.com/iceberg",
                "token": "<jwt-token-from-login>",
            }
        )
        
        # List tables in your workspace namespace
        tables = catalog.list_tables("633def9656c1")
        
        # Load a table
        table = catalog.load_table("633def9656c1.detail")
        ```
        
        ## curl Examples
        
        ```bash
        # Get config
        curl https://api.dpf-it.com/iceberg/v1/config \
          -H "Authorization: Bearer <token>"
        
        # List namespaces
        curl https://api.dpf-it.com/iceberg/v1/namespaces \
          -H "Authorization: Bearer <token>"
        
        # List tables in a namespace
        curl https://api.dpf-it.com/iceberg/v1/namespaces/633def9656c1/tables \
          -H "Authorization: Bearer <token>"
        
        # Load table metadata
        curl https://api.dpf-it.com/iceberg/v1/namespaces/633def9656c1/tables/detail \
          -H "Authorization: Bearer <token>"
        ```

        ## Time Travel & Rollback

        Iceberg retains every snapshot of a table, so you can **read** the table as
        of any past snapshot, and (with `full` permission) **roll back** the table's
        current state to a past snapshot. Both go through this REST interface — no
        special endpoint is needed:

        - Reading the history and as-of reads use `LoadTable` (`GET .../tables/{t}`).
        - Rollback is a standard **`UpdateTable` commit** (`POST .../tables/{t}`) that
          repoints the table's `main` ref at an older snapshot via a `set-snapshot-ref`
          metadata update. There is no dedicated "rollback" verb in the Iceberg REST
          spec — Iceberg clients build this commit for you (shown below).

        ### Step 1 — Inspect snapshot history (find the snapshot to roll back to)

        ```python
        from pyiceberg.catalog import load_catalog

        catalog = load_catalog("dpf", **{
            "type": "rest",
            "uri": "https://api.dpf-it.com/iceberg",
            "token": "<jwt-token-from-login>",
        })

        table = catalog.load_table("633def9656c1.detail")

        # Full snapshot log: id, parent, timestamp, operation, summary
        for s in table.metadata.snapshots:
            print(s.snapshot_id, s.parent_snapshot_id,
                  s.timestamp_ms, s.summary.operation)

        # Current head of `main`
        print("current:", table.current_snapshot().snapshot_id)

        # Read the table AS OF a past snapshot (does not change the table)
        df = table.scan(snapshot_id=2845093187264590133).to_arrow()
        ```

        The same history is visible over raw HTTP — `LoadTable` returns the
        `metadata.snapshots` and `metadata.snapshot-log` arrays:

        ```bash
        curl https://api.dpf-it.com/iceberg/v1/namespaces/633def9656c1/tables/detail \
          -H "Authorization: Bearer <token>" \
          | jq '.metadata."snapshot-log", .metadata."current-snapshot-id"'
        ```

        > **Snapshot IDs are random 64-bit values, not sequential.** A newer snapshot
        > may have a *smaller* id than an older one — never infer recency from id size.
        > Use `current-snapshot-id` for the head, and the `snapshot-log` / `timestamp-ms`
        > (or the `parent-snapshot-id` chain) to order snapshots in time.

        ### Step 2 — Roll back the table to a chosen snapshot

        Requires `full` (write) permission. Rollback is a single **`UpdateTable`
        commit** (`POST .../tables/{t}`) — there is no separate rollback endpoint.
        The `set-snapshot-ref` update repoints the `main` branch at the target
        snapshot; the `assert-ref-snapshot-id` requirement is optimistic concurrency
        (set it to the **current** head id so a concurrent writer can't be clobbered —
        a mismatch returns `409` and you re-read and retry). This is a metadata-only
        commit; no data files are deleted (the catalog runs with
        `s3.delete-enabled=false`).

        **Direct REST call (no client library needed):**

        ```bash
        curl -X POST \
          https://api.dpf-it.com/iceberg/v1/namespaces/633def9656c1/tables/detail \
          -H "Authorization: Bearer <token>" \
          -H "Content-Type: application/json" \
          -d '{
            "requirements": [
              { "type": "assert-ref-snapshot-id", "ref": "main", "snapshot-id": 7100456012345678901 }
            ],
            "updates": [
              { "action": "set-snapshot-ref", "ref-name": "main", "type": "branch", "snapshot-id": 2845093187264590133 }
            ]
          }'
        ```

        Client libraries just build that same commit for you (read the current head,
        fill in the requirement, retry on conflict):

        ```python
        # PyIceberg
        table.manage_snapshots().rollback_to_snapshot(2845093187264590133).commit()
        ```

        ```sql
        -- Spark (Iceberg extension)
        CALL system.rollback_to_snapshot('633def9656c1.detail', 2845093187264590133);
        ```

        ### ⚠️ Rollback then write creates a fork that cannot be auto-merged

        Snapshots are immutable, complete states — not diffs — and Iceberg has **no
        merge** operation. If you roll back `main` to an old snapshot and then write
        new data, the new snapshot's parent is the rollback target, and the snapshots
        you rolled back past become **orphaned** (still readable by id via time travel,
        but no longer on `main`):

        ```
        S1 → S2 → S3 → S4        # S3, S4 orphaned after rollback to S2
                ↘ S5             # new write; main now points here, parent = S2
        ```

        `main` is just a movable pointer — you can repoint it to *either* head, but
        that selects one lineage; it does not combine them. To recover data from the
        orphaned lineage you must re-insert it manually (e.g. read `... AS OF <S4>` and
        `INSERT` the rows you want). Orphaned snapshots survive only until snapshot
        expiration runs, after which that lineage is unrecoverable. Treat rollback as
        "undo to a known-good point," not as a branching workflow.

        ## Snapshot Retention & Storage Cleanup

        Every table keeps its full snapshot history, which steadily grows storage.
        DPF expires old snapshots automatically and reclaims the underlying S3 files:

        - **Default:** new tables are created with a **30-day** snapshot retention
          policy (`history.expire.max-snapshot-age-ms = 2592000000`) and always keep
          at least the current snapshot (`history.expire.min-snapshots-to-keep = 1`).
        - **Enforcement:** a managed maintenance job runs on a schedule to expire
          snapshots older than the retention period and **delete the data/metadata
          files they no longer reference**, plus remove orphaned files left by failed
          writes. The current (live) snapshot is never expired.

        Retention is just a standard Iceberg table property, so you change it with a
        normal **`UpdateTable` commit** (`set-properties`) — no special endpoint. The
        value is in **milliseconds** (e.g. 7 days = `604800000`).

        **Direct REST call:**

        ```bash
        # Change snapshot retention to 7 days
        curl -X POST \
          https://api.dpf-it.com/iceberg/v1/namespaces/633def9656c1/tables/detail \
          -H "Authorization: Bearer <token>" \
          -H "Content-Type: application/json" \
          -d '{
            "requirements": [],
            "updates": [
              { "action": "set-properties",
                "updates": { "history.expire.max-snapshot-age-ms": "604800000" } }
            ]
          }'
        ```

        Client libraries set the same property:

        ```python
        # PyIceberg — 7 days in ms
        table.transaction().set_properties(**{
            "history.expire.max-snapshot-age-ms": "604800000"
        }).commit_transaction()
        ```

        ```sql
        -- Spark (Iceberg extension)
        ALTER TABLE catalog.`633def9656c1`.detail
          SET TBLPROPERTIES ('history.expire.max-snapshot-age-ms' = '604800000');
        ```

        > Requires `full` (write) permission. Lowering retention takes effect on the
        > next maintenance run; it does not delete anything synchronously.

        ## Reference Documentation
        
        For full details on the Iceberg REST Catalog API specification, see:
        - [Apache Iceberg REST Catalog Spec](https://iceberg.apache.org/rest-catalog-spec)
        - [Iceberg REST OpenAPI YAML](https://github.com/apache/iceberg/blob/main/open-api/rest-catalog-open-api.yaml)
        - [Iceberg REST Endpoint Reference](https://docs.aws.amazon.com/glue/latest/dg/connect-glu-iceberg-rest.html)
      operationId: icebergRestCatalogConfig
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Catalog configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  defaults:
                    type: object
                    description: Default catalog properties
                  overrides:
                    type: object
                    description: Override properties
              example:
                defaults:
                  io-impl: org.apache.iceberg.aws.s3.S3FileIO
                  write.object-storage.enabled: "true"
                  write.object-storage.partitioned-paths: "false"
                  s3.delete-enabled: "false"
                  rest-metrics-reporting-enabled: "false"
                overrides: {}

  /oauth/authorize:
    get:
      tags:
        - OAuth 2.1 Authorization Server
      summary: Authorization endpoint (hosted login page)
      description: |
        Renders a real, hosted HTML login page — not a JSON API. This is where the actual login
        form lives; the password is typed here, in the browser, never inside an MCP tool call or
        chat context. PKCE (S256) is mandatory, matching OAuth 2.1. On success, redirects to
        `redirect_uri` with a short-lived, single-use `code`.
      operationId: oauthAuthorizeGet
      parameters:
        - name: client_id
          in: query
          required: true
          schema: { type: string }
        - name: redirect_uri
          in: query
          required: true
          schema: { type: string }
          description: Must exactly match one of the client's registered redirect URIs
        - name: response_type
          in: query
          required: true
          schema: { type: string, enum: [code] }
        - name: code_challenge
          in: query
          required: true
          schema: { type: string }
        - name: code_challenge_method
          in: query
          required: true
          schema: { type: string, enum: [S256] }
        - name: state
          in: query
          required: false
          schema: { type: string }
      responses:
        '200':
          description: Login page rendered (text/html)
        '400':
          description: Invalid client_id, redirect_uri, or missing/invalid PKCE parameters
    post:
      tags:
        - OAuth 2.1 Authorization Server
      summary: Login form submission
      description: |
        Handles the login form's own POST (application/x-www-form-urlencoded: email, password, plus
        the same OAuth params carried through as hidden fields). Not intended to be called directly
        by API clients — this is the target of the form rendered by GET /oauth/authorize.
      operationId: oauthAuthorizePost
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                email: { type: string }
                password: { type: string }
                client_id: { type: string }
                redirect_uri: { type: string }
                response_type: { type: string }
                code_challenge: { type: string }
                code_challenge_method: { type: string }
                state: { type: string }
      responses:
        '302':
          description: Login succeeded — redirects to redirect_uri with ?code=...&state=...
        '200':
          description: Login failed — re-renders the login page with an error message (text/html)

  /oauth/token:
    post:
      tags:
        - OAuth 2.1 Authorization Server
      summary: Token endpoint (authorization_code, refresh_token, and client_credentials grants)
      description: |
        Standard OAuth 2.1 token endpoint. Responses use the standard OAuth2 error shape
        (`{ error, error_description }`), not DPF's usual `{ error: { code, message } }` envelope —
        this endpoint is consumed by generic OAuth2 clients (VS Code, Cursor, Claude), so spec
        compliance wins over internal convention here.

        **grant_type=authorization_code**: exchanges a code (from /oauth/authorize) + code_verifier
        for an access token + refresh token. Single-use — the code is burned on first use regardless
        of outcome.

        **grant_type=refresh_token**: rotates the presented refresh token (issues a new one, invalidates
        the old). Reusing an already-rotated token is treated as a theft signal and revokes the entire
        token family, forcing full re-authentication.

        **grant_type=client_credentials**: machine-to-machine auth for a confidential client
        registered via `POST /oauth/clients` (e.g. for AWS Bedrock AgentCore Gateway). No refresh
        token is issued — re-authenticate with `client_id`/`client_secret` each time. The resulting
        access token is a standard user-shaped JWT tied to whichever account created the client, so
        it works against every DPF API, the MCP server (`/mcp`), and the Iceberg REST catalog
        (`/iceberg/v1/*`) across *all* of that account's workspaces — not just one.

        Access tokens are the same 24h user JWT issued by `/auth/login`.
      operationId: oauthToken
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                grant_type: { type: string, enum: [authorization_code, refresh_token, client_credentials] }
                code: { type: string, description: "Required for authorization_code" }
                redirect_uri: { type: string, description: "Required for authorization_code" }
                code_verifier: { type: string, description: "Required for authorization_code (PKCE)" }
                refresh_token: { type: string, description: "Required for refresh_token" }
                client_id: { type: string, required: true }
                client_secret: { type: string, description: "Required for client_credentials" }
      responses:
        '200':
          description: Token issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  refresh_token: { type: string }
                  token_type: { type: string, example: Bearer }
                  expires_in: { type: integer, example: 86400 }
        '400':
          description: invalid_request, invalid_grant, or unsupported_grant_type
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  error_description: { type: string }

  /oauth/register:
    post:
      tags:
        - OAuth 2.1 Authorization Server
      summary: Dynamic Client Registration (RFC 7591)
      description: |
        Lets a spec-compliant MCP client (VS Code, Cursor, Claude) self-register with zero manual
        setup on the DPF side. Only public clients (PKCE, no secret) are issued —
        `token_endpoint_auth_method` is always `none` in the response, regardless of what was
        requested, since PKCE alone is a complete, secure flow for the native/browser-based clients
        this server targets.
      operationId: oauthRegister
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [redirect_uris]
              properties:
                client_name: { type: string }
                redirect_uris:
                  type: array
                  items: { type: string }
      responses:
        '201':
          description: Client registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  client_id: { type: string }
                  client_name: { type: string }
                  redirect_uris: { type: array, items: { type: string } }
                  token_endpoint_auth_method: { type: string, example: none }
                  client_id_issued_at: { type: integer }
                  grant_types: { type: array, items: { type: string } }
                  response_types: { type: array, items: { type: string } }
        '400':
          description: invalid_client_metadata or invalid_redirect_uri

  /oauth/clients:
    post:
      tags:
        - OAuth 2.1 Authorization Server
      summary: Register a confidential client (client_credentials)
      description: |
        Creates a confidential OAuth client tied to the calling account, for the
        `client_credentials` grant — DPF's recommended way to set up machine-to-machine access
        (AWS Bedrock AgentCore Gateway, CI pipelines, etc.). Unlike `/oauth/register`, this requires
        a logged-in user (`Authorization: Bearer <user JWT>`) and issues a `client_secret` — shown
        exactly once in the response, only its bcrypt hash is persisted. Uses DPF's usual
        `{ success, data }` / `{ error }` envelope (not the OAuth2 error shape), since it's a DPF
        API endpoint, not part of the OAuth2 spec surface.
      operationId: oauthCreateClient
      security:
        - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                client_name: { type: string, description: "Defaults to 'Unnamed service client'" }
      responses:
        '201':
          description: Client created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data:
                    type: object
                    properties:
                      clientId: { type: string }
                      clientSecret: { type: string, description: "Shown once — not recoverable afterward" }
                      clientName: { type: string }
        '401':
          description: Missing/invalid Authorization header
    get:
      tags:
        - OAuth 2.1 Authorization Server
      summary: List your confidential clients
      description: Lists the caller's own confidential clients (never includes secrets/hashes).
      operationId: oauthListClients
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Clients listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data:
                    type: object
                    properties:
                      clients:
                        type: array
                        items:
                          type: object
                          properties:
                            clientId: { type: string }
                            clientName: { type: string }
                            createdAt: { type: integer }

  /oauth/clients/revoke:
    post:
      tags:
        - OAuth 2.1 Authorization Server
      summary: Revoke a confidential client
      description: |
        Deletes one of the caller's own confidential clients, immediately invalidating its
        `client_id`/`client_secret` for future `client_credentials` exchanges (already-issued access
        tokens remain valid until their own 24h expiry). Returns `404` for both an unknown
        `client_id` and one owned by a different account — the two cases aren't distinguished, to
        avoid leaking existence of other users' clients.
      operationId: oauthRevokeClient
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id]
              properties:
                client_id: { type: string }
      responses:
        '200':
          description: Client revoked
        '404':
          description: Unknown client_id (or not owned by the caller)

  /mcp:
    post:
      tags:
        - MCP
      summary: Remote MCP server (Streamable HTTP, JSON-RPC 2.0)
      description: |
        Single endpoint implementing the MCP Streamable HTTP transport, stateless (no session is
        held between requests — every request is independently authenticated and dispatched).
        Responses are always direct JSON (never SSE) since every tool here is a synchronous
        request/response against the DPF REST API.

        **Auth is required on every request, including `initialize`.** Send
        `Authorization: Bearer <token>`, where the token is either an OAuth 2.1 access token (see
        `/oauth/token`) or a plain `/auth/login` JWT. An unauthenticated request gets a `401` with a
        `WWW-Authenticate` header pointing at the authorization/token endpoints — this is what makes
        OAuth-aware clients (VS Code, Cursor, Claude) discover they need to authenticate and run the
        OAuth flow automatically.

        Body is a standard JSON-RPC 2.0 MCP message (`initialize`, `tools/list`, `tools/call`, etc.)
        — see the Model Context Protocol specification for the message format. Tools available:
        list_my_workspaces, create_workspace, list_data, get_status, delete_data_spec, submit_query,
        manage_connection, manage_trigger, onboard_data_source, finish_data_source_onboarding,
        update_data_spec, finish_data_spec_update, run_data_job, finish_data_job,
        setup_scheduled_pull, call_dpf_api.
      operationId: mcpRemote
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: JSON-RPC 2.0 MCP message
      responses:
        '200':
          description: JSON-RPC response
          content:
            application/json:
              schema:
                type: object
        '401':
          description: Missing or invalid Authorization header
        '405':
          description: Method not allowed — only POST is supported on this endpoint

  /iceberg/v1/namespaces:
    get:
      tags:
        - Iceberg REST Catalog
      summary: List namespaces (filtered by workspace access)
      operationId: icebergListNamespaces
      security:
        - BearerAuth: []
      responses:
        '200':
          description: List of accessible namespaces
          content:
            application/json:
              schema:
                type: object
                properties:
                  namespaces:
                    type: array
                    items:
                      type: array
                      items:
                        type: string
              example:
                namespaces:
                  - ["633def9656c1"]
    post:
      tags:
        - Iceberg REST Catalog
      summary: Create namespace (BLOCKED - use Workspace API)
      description: |
        **This operation is not allowed.** Namespaces are managed through workspace lifecycle.
        Use `POST /workspaces` with action `create` to create a workspace, which provisions the namespace automatically.
      operationId: icebergCreateNamespace
      security:
        - BearerAuth: []
      responses:
        '405':
          description: Operation not allowed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: OPERATION_NOT_ALLOWED
                  message: 'Creating namespaces directly is not supported. Use POST /workspaces with action "create" to create a workspace, which provisions the namespace automatically.'

  /iceberg/v1/namespaces/{namespace}/tables:
    get:
      tags:
        - Iceberg REST Catalog
      summary: List tables in a namespace
      operationId: icebergListTables
      security:
        - BearerAuth: []
      parameters:
        - name: namespace
          in: path
          required: true
          schema:
            type: string
          description: Namespace identifier (last 12 characters of workspaceId)
          example: 633def9656c1
      responses:
        '200':
          description: List of tables in the namespace
          content:
            application/json:
              schema:
                type: object
                properties:
                  identifiers:
                    type: array
                    items:
                      type: object
                      properties:
                        namespace:
                          type: array
                          items:
                            type: string
                        name:
                          type: string
              example:
                identifiers:
                  - namespace: ["633def9656c1"]
                    name: detail

    post:
      tags:
        - Iceberg REST Catalog
      summary: Create a new Iceberg table
      operationId: icebergCreateTable
      security:
        - BearerAuth: []
      description: |
        Create a new Iceberg table in the specified namespace.

        ## Permission

        Requires **full** permission on the workspace that owns the namespace.

        ## Schema Definition

        The `schema` object follows the Apache Iceberg schema specification:
        - Each field has an `id`, `name`, `type`, and `required` flag
        - Supported types: `boolean`, `int`, `long`, `float`, `double`, `decimal(p,s)`,
          `date`, `time`, `timestamp`, `timestamptz`, `string`, `uuid`, `fixed[L]`, `binary`
        - Nested types: `struct`, `list`, `map`

        ## Partition Spec (Optional)

        Define how the table is partitioned for efficient queries:
        - Transforms: `identity`, `bucket[N]`, `truncate[W]`, `year`, `month`, `day`, `hour`

        ## Storage

        The table's data location is managed automatically by the catalog.
        Do **not** specify a `location` property — the service determines storage paths.

        ## Notes

        - Table names must be unique within a namespace
        - After creation, the table has zero snapshots (empty)
        - Use the data pipeline (`create-job` + `start-job`) or direct Iceberg append operations to load data
      parameters:
        - name: namespace
          in: path
          required: true
          schema:
            type: string
          description: Namespace identifier (last 12 characters of workspaceId)
          example: 633def9656c1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - schema
              properties:
                name:
                  type: string
                  description: Table name
                  example: customers
                schema:
                  type: object
                  description: Iceberg table schema
                  properties:
                    type:
                      type: string
                      enum: [struct]
                    fields:
                      type: array
                      items:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          type:
                            type: string
                          required:
                            type: boolean
                partition-spec:
                  type: object
                  description: Optional partition specification
                  properties:
                    fields:
                      type: array
                      items:
                        type: object
                        properties:
                          source-id:
                            type: integer
                          transform:
                            type: string
                          name:
                            type: string
                          field-id:
                            type: integer
                sort-order:
                  type: object
                  description: Optional sort order specification
                  properties:
                    fields:
                      type: array
                      items:
                        type: object
                properties:
                  type: object
                  description: Optional table properties (key-value pairs)
                  additionalProperties:
                    type: string
            examples:
              simpleTable:
                summary: Create a simple table (with a primary key)
                description: |
                  `dpf.primary-keys` is DPF's convention for declaring a table's natural key
                  (Iceberg has no native primary-key concept) — comma-separate multiple columns
                  for a composite key. It's optional at creation; omit it for an append-only
                  table. It's required before a merge-enabled DPF spec (`merge: true` +
                  `targetOption: existing-tables`) can target this table — see the
                  setPrimaryKey/removePrimaryKey examples on `POST .../tables/{table}` to add
                  or change it later on a table that already exists.
                value:
                  name: customers
                  schema:
                    type: struct
                    fields:
                      - id: 1
                        name: customer_id
                        type: string
                        required: true
                      - id: 2
                        name: name
                        type: string
                        required: false
                      - id: 3
                        name: email
                        type: string
                        required: true
                      - id: 4
                        name: created_at
                        type: timestamptz
                        required: true
                  properties:
                    dpf.primary-keys: customer_id
              partitionedTable:
                summary: Create a partitioned table
                value:
                  name: transactions
                  schema:
                    type: struct
                    fields:
                      - id: 1
                        name: transaction_id
                        type: string
                        required: true
                      - id: 2
                        name: amount
                        type: "decimal(18,2)"
                        required: true
                      - id: 3
                        name: transaction_date
                        type: date
                        required: true
                      - id: 4
                        name: customer_id
                        type: string
                        required: true
                  partition-spec:
                    fields:
                      - source-id: 3
                        transform: month
                        name: transaction_month
                        field-id: 1000
      responses:
        '200':
          description: Table created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  metadata-location:
                    type: string
                    description: Storage path to the initial metadata.json file
                  metadata:
                    type: object
                    description: Full table metadata (schema, partition spec, empty snapshot list)
              example:
                metadata-location: "s3://dpf-storage/633def9656c1/customers/metadata/00000-init.metadata.json"
                metadata:
                  format-version: 2
                  table-uuid: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
                  current-snapshot-id: -1
                  snapshots: []
                  schemas:
                    - type: struct
                      schema-id: 0
                      fields:
                        - id: 1
                          name: customer_id
                          type: string
                          required: true
                        - id: 2
                          name: name
                          type: string
                          required: false
                        - id: 3
                          name: email
                          type: string
                          required: true
                        - id: 4
                          name: created_at
                          type: timestamptz
                          required: true
        '409':
          description: Table already exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: "ALREADY_EXISTS"
                  message: "Table already exists: 633def9656c1.customers"

  /iceberg/v1/namespaces/{namespace}/tables/{table}:
    get:
      tags:
        - Iceberg REST Catalog
      summary: Load table metadata (includes snapshot history)
      operationId: icebergLoadTable
      security:
        - BearerAuth: []
      description: |
        Load full table metadata including schema, partition spec, sort order, and **snapshot history**.

        This is the primary endpoint for **time travel** — the response includes all snapshots (or those
        referenced by branches/tags), allowing you to see the complete history of changes to a table.

        ## Snapshot History

        The response metadata contains:
        - `current-snapshot-id`: The active snapshot (what queries read by default)
        - `snapshots`: Array of all historical snapshots with IDs, timestamps, and summaries
        - `snapshot-log`: Chronological record of which snapshot was current at each point in time
        - `refs`: Named references (branches/tags) pointing to specific snapshots

        Each snapshot includes:
        - `snapshot-id`: Unique identifier (use this for time-travel queries or rollback)
        - `timestamp-ms`: When the snapshot was committed (epoch milliseconds)
        - `summary`: Operation type (`append`, `overwrite`, `delete`), record counts, file counts
        - `parent-snapshot-id`: The previous snapshot (forms the lineage chain)

        ## Query Parameter: snapshots

        - `?snapshots=all` (default) — return all snapshots in the table's history
        - `?snapshots=refs` — return only snapshots referenced by branches or tags (smaller response)

        ## Using Snapshot IDs

        Once you have a snapshot ID from this response, you can:
        1. **Time-travel query** (via DuckDB/Athena): `SELECT * FROM table AT (VERSION => <snapshot-id>)`
        2. **Rollback** (via POST to this same path): Set the `main` ref back to a prior snapshot
        3. **Audit**: Trace data lineage by walking the parent chain
      parameters:
        - name: namespace
          in: path
          required: true
          schema:
            type: string
          description: Namespace identifier (last 12 characters of workspaceId)
          example: 633def9656c1
        - name: table
          in: path
          required: true
          schema:
            type: string
          description: Table name
          example: customers
        - name: snapshots
          in: query
          required: false
          schema:
            type: string
            enum: [all, refs]
            default: all
          description: Which snapshots to include in the response
      responses:
        '200':
          description: Table metadata with snapshot history
          content:
            application/json:
              schema:
                type: object
                properties:
                  metadata-location:
                    type: string
                    description: Storage path to the metadata.json file
                  metadata:
                    type: object
                    properties:
                      format-version:
                        type: integer
                        example: 2
                      table-uuid:
                        type: string
                        format: uuid
                      current-snapshot-id:
                        type: integer
                        format: int64
                        description: The snapshot currently read by queries
                      snapshots:
                        type: array
                        description: All snapshots in the table's history
                        items:
                          type: object
                          properties:
                            snapshot-id:
                              type: integer
                              format: int64
                            parent-snapshot-id:
                              type: integer
                              format: int64
                              nullable: true
                            timestamp-ms:
                              type: integer
                              format: int64
                              description: Commit time in epoch milliseconds
                            summary:
                              type: object
                              properties:
                                operation:
                                  type: string
                                  enum: [append, overwrite, replace, delete]
                                added-records:
                                  type: string
                                total-records:
                                  type: string
                                added-data-files:
                                  type: string
                                total-data-files:
                                  type: string
                            manifest-list:
                              type: string
                      snapshot-log:
                        type: array
                        description: Chronological log of current-snapshot changes
                        items:
                          type: object
                          properties:
                            snapshot-id:
                              type: integer
                              format: int64
                            timestamp-ms:
                              type: integer
                              format: int64
                      refs:
                        type: object
                        description: Named references (branches and tags)
                        additionalProperties:
                          type: object
                          properties:
                            snapshot-id:
                              type: integer
                              format: int64
                            type:
                              type: string
                              enum: [branch, tag]
              example:
                metadata-location: "s3://dpf-storage/633def9656c1/customers/metadata/00003-abc123.metadata.json"
                metadata:
                  format-version: 2
                  table-uuid: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                  current-snapshot-id: 3497810964824022504
                  snapshots:
                    - snapshot-id: 1284560972800747000
                      parent-snapshot-id: null
                      timestamp-ms: 1717948800000
                      summary:
                        operation: "append"
                        added-records: "15000"
                        total-records: "15000"
                        added-data-files: "3"
                        total-data-files: "3"
                      manifest-list: "s3://dpf-storage/633def9656c1/customers/metadata/snap-1284560972800747000.avro"
                    - snapshot-id: 3497810964824022504
                      parent-snapshot-id: 1284560972800747000
                      timestamp-ms: 1718035200000
                      summary:
                        operation: "append"
                        added-records: "8500"
                        total-records: "23500"
                        added-data-files: "2"
                        total-data-files: "5"
                      manifest-list: "s3://dpf-storage/633def9656c1/customers/metadata/snap-3497810964824022504.avro"
                  snapshot-log:
                    - snapshot-id: 1284560972800747000
                      timestamp-ms: 1717948800000
                    - snapshot-id: 3497810964824022504
                      timestamp-ms: 1718035200000
                  refs:
                    main:
                      snapshot-id: 3497810964824022504
                      type: "branch"
        '404':
          description: Table not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      tags:
        - Iceberg REST Catalog
      summary: Commit updates to a table (including rollback via set-snapshot-ref)
      operationId: icebergUpdateTable
      security:
        - BearerAuth: []
      description: |
        Commit metadata updates to a table. This is the standard Iceberg REST Catalog commit endpoint
        used for schema evolution, partition changes, and — critically — **rolling back to a prior snapshot**.

        ## Rollback via set-snapshot-ref

        To roll a table back to a previous state, send a commit that sets the `main` branch reference
        to a prior snapshot ID. This does not delete data — it simply moves the pointer so that queries
        read the older snapshot.

        **Steps to rollback:**
        1. `GET /iceberg/v1/namespaces/{ns}/tables/{table}` — load metadata, find the target snapshot ID
        2. `POST /iceberg/v1/namespaces/{ns}/tables/{table}` — commit a `set-snapshot-ref` update

        ## Primary Key via set-properties / remove-properties

        `dpf.primary-keys` is a plain table property, so it's added, replaced, or removed the
        same way as any other — no special endpoint. Manage it directly via this Iceberg REST
        interface; see the `setPrimaryKey`/`removePrimaryKey` examples below.

        ## Requirements (Optimistic Concurrency)

        Include `assert-ref-snapshot-id` in the `requirements` array to ensure no other commit has
        changed the table since you read its metadata. If the assertion fails, the server returns 409
        (Conflict) and you must reload metadata and retry.

        ## Updates

        The `updates` array contains one or more operations. For rollback, use:
        - `set-snapshot-ref` with `ref-name: "main"` pointing to the target snapshot ID

        ## Important Notes

        - Rollback does **not** delete data files — they remain in storage
        - Old snapshots may be garbage-collected after the table's retention period expires
        - After rollback, new appends create snapshots branching from the restored state
        - The rolled-back snapshots remain in the history for audit purposes
      parameters:
        - name: namespace
          in: path
          required: true
          schema:
            type: string
          description: Namespace identifier (last 12 characters of workspaceId)
          example: 633def9656c1
        - name: table
          in: path
          required: true
          schema:
            type: string
          description: Table name
          example: customers
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - requirements
                - updates
              properties:
                requirements:
                  type: array
                  description: Assertions validated before applying updates
                  items:
                    type: object
                    properties:
                      type:
                        type: string
                      ref:
                        type: string
                      snapshot-id:
                        type: integer
                        format: int64
                updates:
                  type: array
                  description: Changes to apply to table metadata
                  items:
                    type: object
                    properties:
                      action:
                        type: string
                        description: 'e.g. set-snapshot-ref (rollback), set-properties (add/change properties like dpf.primary-keys), remove-properties (delete properties)'
                      ref-name:
                        type: string
                      snapshot-id:
                        type: integer
                        format: int64
                      type:
                        type: string
                        enum: [branch, tag]
                      updates:
                        type: object
                        description: 'set-properties only: property key/value pairs to set'
                        additionalProperties:
                          type: string
                      removals:
                        type: array
                        description: 'remove-properties only: property keys to delete'
                        items:
                          type: string
            examples:
              rollbackToSnapshot:
                summary: Rollback table to a prior snapshot
                description: |
                  Sets the main branch back to snapshot 1284560972800747000.
                  The requirement asserts the current snapshot is 3497810964824022504
                  (what we read from GET) to prevent lost updates.
                value:
                  requirements:
                    - type: "assert-ref-snapshot-id"
                      ref: "main"
                      snapshot-id: 3497810964824022504
                  updates:
                    - action: "set-snapshot-ref"
                      ref-name: "main"
                      type: "branch"
                      snapshot-id: 1284560972800747000
              setPrimaryKey:
                summary: Add or replace a table's primary key
                description: |
                  Sets the `dpf.primary-keys` property — DPF's convention for a table's natural
                  key (Iceberg has no native primary-key concept), comma-separated for a
                  composite key. Overwrites any primary key already set. Required before a
                  merge-enabled DPF spec (`merge: true` + `targetOption: existing-tables`) can
                  target this table — without one, that load has no key to match rows on and
                  silently falls back to plain append instead of upserting.
                value:
                  requirements: []
                  updates:
                    - action: "set-properties"
                      updates:
                        dpf.primary-keys: "customer_id"
              removePrimaryKey:
                summary: Remove a table's primary key
                description: |
                  Deletes the `dpf.primary-keys` property entirely. After this, any
                  merge-enabled DPF spec (`merge: true` + `targetOption: existing-tables`)
                  targeting this table will fail its next create-spec/update-spec call
                  (`MISSING_PRIMARY_KEY`) until a new key is set.
                value:
                  requirements: []
                  updates:
                    - action: "remove-properties"
                      removals: ["dpf.primary-keys"]
      responses:
        '200':
          description: Commit successful — table metadata updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  metadata-location:
                    type: string
                  metadata:
                    type: object
                    description: Updated table metadata reflecting the new current snapshot
        '404':
          description: Table not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Conflict — requirement assertion failed (table was modified concurrently)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: "COMMIT_FAILED"
                  message: "Requirement failed: branch main is at snapshot 9876543210, expected 3497810964824022504"

    delete:
      tags:
        - Iceberg REST Catalog
      summary: Drop (delete) an Iceberg table
      operationId: icebergDropTable
      security:
        - BearerAuth: []
      description: |
        Drop an Iceberg table from the catalog. This removes the table's metadata from the catalog.

        ## Permission

        Requires **full** permission on the workspace that owns the namespace.

        ## Behavior

        - The table is removed from the Glue Catalog
        - By default, data files in S3 are **not** deleted (they become orphaned)
        - Use the `purgeRequested` query parameter to also delete the underlying data files

        ## purgeRequested Parameter

        - `purgeRequested=true`: In addition to removing the catalog entry, the DPF proxy deletes
          every data and metadata file under the table's managed S3 prefix. (Glue's Iceberg REST
          endpoint rejects purge for its managed tables, so the proxy forwards the catalog drop
          with purge off and performs the storage deletion itself.) Storage cleanup is best-effort:
          if it fails, the catalog drop still succeeds.
        - `purgeRequested=false` (default): Only removes the catalog entry. Data files remain in S3
          and can be cleaned up later.

        ## Important Notes

        - This operation is **irreversible** — once dropped, the table cannot be recovered from the catalog
        - With `purgeRequested=true` the underlying files are permanently deleted; with the default they
          become orphaned and can be reclaimed by re-registering the table using the metadata file path
        - Any active jobs writing to this table will fail after the drop
        - Snapshots and history are lost from the catalog perspective
      parameters:
        - name: namespace
          in: path
          required: true
          schema:
            type: string
          description: Namespace identifier (last 12 characters of workspaceId)
          example: 633def9656c1
        - name: table
          in: path
          required: true
          schema:
            type: string
          description: Table name to drop
          example: customers
        - name: purgeRequested
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: If true, request that the catalog also deletes underlying data files
      responses:
        '204':
          description: Table dropped successfully (no content)
        '404':
          description: Table not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: "NOT_FOUND"
                  message: "Table does not exist: 633def9656c1.customers"

  /query:
    post:
      tags:
        - Query
      summary: Execute SQL query against Iceberg tables via DuckDB
      description: |
        Lightweight SQL query interface powered by DuckDB with the Iceberg extension.
        Executes queries directly against Iceberg tables for a single authorized namespace.

        ## How It Works

        1. User specifies a `namespace` (derived from workspaceId, last 12 chars)
        2. The service validates the user has workspace access to that namespace
        3. DuckDB attaches ONLY that namespace via the Iceberg REST endpoint
        4. The SQL query executes with table references scoped to that namespace
        5. Results are returned as JSON (columns + rows)

        ## Security Model

        - Only the requested namespace is attached — no cross-namespace access is possible
        - Dangerous statements (`ATTACH`, `DETACH`, `CREATE SECRET`, `DROP SECRET`, `INSTALL`, `LOAD`, `USE`, `SET`) are blocked
        - The service's role provides read access to the catalog and storage; user authorization is handled at the application layer

        ## Query Syntax

        Since the namespace is pre-selected via `USE`, table references are unqualified:

        ```sql
        SELECT * FROM my_table LIMIT 10
        SELECT COUNT(*) FROM customers WHERE status = 'active'
        DESCRIBE orders
        SHOW TABLES
        ```

        Table DML (`INSERT`/`UPDATE`/`DELETE`/`MERGE`) is also permitted for
        non-read-only members; each is committed durably to the Iceberg catalog.
        Table lifecycle (`CREATE`/`DROP`/general `ALTER`) is intentionally NOT
        supported here — use the Iceberg REST Catalog API instead.

        **Exception:** `ALTER TABLE ... ADD/DROP PRIMARY KEY` IS supported, as a
        special case handled outside DuckDB entirely (neither DuckDB nor Iceberg
        has a native primary-key concept). It sets or removes the same
        `dpf.primary-keys` table property documented on the iceberg-rest-catalog's
        `setPrimaryKey`/`removePrimaryKey` examples:

        ```sql
        ALTER TABLE customers ADD PRIMARY KEY (customer_id)
        ALTER TABLE order_lines ADD PRIMARY KEY (order_id, line_no)
        ALTER TABLE customers DROP PRIMARY KEY
        ```

        `ADD PRIMARY KEY` overwrites any key already set and rejects unknown
        column names; `DROP PRIMARY KEY` is safe to run even if no key is set.

        ## Limitations

        - **Read-only**: Only SELECT, DESCRIBE, SHOW queries are meaningful (data modification is blocked by IAM)
        - **Max query length**: 10,000 characters
        - **Timeout**: 60 seconds
        - **Cold start**: First invocation may take 10-15s to download DuckDB extensions
        - **Result size**: Large result sets may exceed response limits (6MB)

        ## DuckDB Features Available

        Full DuckDB SQL syntax is available for analytical queries:
        - Aggregations, window functions, CTEs
        - JOINs across tables within the same namespace
        - LIMIT, ORDER BY, GROUP BY
        - Iceberg time-travel: `SELECT * FROM table AT (VERSION => snapshot_id)`
        - Iceberg metadata: `SELECT * FROM iceberg_snapshots('catalog.ns.table')`
      operationId: executeQuery
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/QueryRequest'
            examples:
              selectRows:
                summary: Select rows from a table
                value:
                  namespace: 633def9656c1
                  query: SELECT * FROM detail LIMIT 10
              countRows:
                summary: Count rows
                value:
                  namespace: 633def9656c1
                  query: SELECT COUNT(*) as total FROM detail
              describeTable:
                summary: Describe table schema
                value:
                  namespace: 633def9656c1
                  query: DESCRIBE detail
              showTables:
                summary: List all tables
                value:
                  namespace: 633def9656c1
                  query: SHOW TABLES
              aggregation:
                summary: Aggregation query
                value:
                  namespace: 633def9656c1
                  query: SELECT dpf_filename, COUNT(*) as rows FROM detail GROUP BY dpf_filename
              addPrimaryKeyViaQuery:
                summary: Add or replace a table's primary key
                description: |
                  Special-cased outside DuckDB: sets the `dpf.primary-keys` table
                  property (comma-separated for a composite key), the same
                  property the `setPrimaryKey` example on the iceberg-rest-catalog
                  endpoint sets directly. Overwrites any key already set; rejects
                  column names that don't exist on the table.
                value:
                  namespace: 633def9656c1
                  query: ALTER TABLE customers ADD PRIMARY KEY (customer_id)
              dropPrimaryKeyViaQuery:
                summary: Remove a table's primary key
                description: |
                  Removes the `dpf.primary-keys` property entirely. Safe to run
                  even if the table has no primary key set.
                value:
                  namespace: 633def9656c1
                  query: ALTER TABLE customers DROP PRIMARY KEY
      responses:
        '200':
          description: Query executed successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueryResponse'
        '500':
          description: |
            Internal server error. Error codes:
            - `QUERY_SETUP_FAILED`: DuckDB initialization or catalog attachment failed
            - `INTERNAL_SERVER_ERROR`: Unexpected error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /query-large:
    post:
      tags:
        - Query
      summary: Execute SQL query (large compute)
      description: |
        High-resource variant of the `/query` endpoint. Identical request/response format and behavior,
        but runs with 4× the memory and CPU allocation (7076 MB / ~4 vCPU) and a 120-second timeout.

        Use this endpoint for queries that are too resource-intensive for the standard `/query` endpoint,
        such as large table scans, complex joins, or heavy aggregations.

        **Billing:** Each query executed on this endpoint consumes 4× the credits of a standard `/query` request.

        See the [/query](#/Query/executeQuery) endpoint for full documentation on request format,
        security model, query syntax, and available DuckDB features.
      operationId: executeQueryLarge
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/QueryRequest'
            examples:
              heavyAggregation:
                summary: Large aggregation query
                value:
                  namespace: 633def9656c1
                  query: SELECT dpf_filename, COUNT(*) as rows, MIN(dpf_ts) as first_load, MAX(dpf_ts) as last_load FROM detail GROUP BY dpf_filename ORDER BY rows DESC
      responses:
        '200':
          description: Query executed successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueryResponse'

  /contact:
    post:
      tags:
        - Contact
      summary: Submit a contact form message
      description: |
        Contact form endpoint that operates in two modes:

        ## Authenticated Mode (with JWT)

        When a valid `Authorization: Bearer <token>` header is provided, the user's name and email
        are automatically retrieved from their account. The `email` and `name` fields in the request
        body are ignored — only `reason` and `message` are required.

        The contact email sent to the DPF team will include the user's full name, email, and user ID.

        ## Unauthenticated Mode (pre-login)

        When no Authorization header is present, the `email` field is **required** in the request body
        so the team can respond. The `name` field is optional.

        ## Common Behavior

        - Messages are forwarded to the DPF team via email (contact@dpf-it.com)
        - Rate-limited by IP address
        - Reply-To is set to the sender's email for easy follow-up
      operationId: submitContactForm
      security:
        - BearerAuth: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactRequest'
            examples:
              authenticatedUser:
                summary: Authenticated user (no email/name needed)
                description: When logged in, only reason and message are required. User info is pulled from the account.
                value:
                  reason: request-a-feature
                  message: "It would be great to have a bulk delete option for data specs."
              unauthenticatedDemo:
                summary: Unauthenticated - request a demo
                description: Pre-login users must provide an email address.
                value:
                  reason: request-a-demo
                  email: prospect@company.com
                  name: Jane Smith
                  message: "We'd like to see a demo of the platform for our data team."
              unauthenticatedIssue:
                summary: Unauthenticated - report an issue
                value:
                  reason: report-an-issue
                  email: user@company.com
                  name: John Doe
                  message: "Getting a 500 error when trying to start analysis on my data spec."
      responses:
        '200':
          description: Message sent successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    type: object
                    properties:
                      message:
                        type: string
                        example: Your message has been sent. We will get back to you shortly.

components:
  schemas:
    Connection:
      type: object
      description: A workspace connection. The private key is never included.
      properties:
        connectionId:
          type: string
          format: uuid
          example: 7b3f8a10-1c2d-4e5f-9a0b-1c2d3e4f5a6b
        workspaceId:
          type: string
          format: uuid
          example: 550e8400-e29b-41d4-a716-446655440000
        type:
          type: string
          enum: [sftp, aws_s3]
          example: sftp
        hostname:
          type: string
          description: sftp only.
          example: sftp.example.com
        username:
          type: string
          description: sftp only.
          example: sftpuser
        publicKey:
          type: string
          description: sftp only. OpenSSH public key to install in the remote server's authorized_keys.
          example: ssh-rsa AAAAB3NzaC1yc2EAAA... dpf-446655440000
        roleArn:
          type: string
          description: aws_s3 only. The customer's IAM role ARN.
          example: arn:aws:iam::123456789012:role/dpf-ingestion
        externalId:
          type: string
          description: aws_s3 only. DPF-generated; required in the customer's trust policy.
          example: 3f9c2e1a-5b6d-4c7e-8f9a-0b1c2d3e4f5a
        dpfPrincipalArn:
          type: string
          description: aws_s3 only. The DPF connector role ARN the customer's trust policy must name as principal.
          example: arn:aws:iam::442707444240:role/dpf-aws-connector
        createdBy:
          type: string
          format: uuid
        createdAt:
          type: integer
          format: int64
          description: Unix timestamp in milliseconds
        updatedAt:
          type: integer
          format: int64
          description: Unix timestamp in milliseconds
        lastTestStatus:
          type: string
          enum: [passed, failed]
          description: Outcome of the most recent test-connection call, if any test has been run.
        lastTestedAt:
          type: integer
          format: int64
          description: Unix timestamp in milliseconds of the most recent test-connection call, if any.

    CreateConnectionRequest:
      type: object
      description: |
        `hostname` (and optional `username`) are required when `type: sftp`; `roleArn` is required when
        `type: aws_s3`.
      required:
        - action
        - workspaceId
        - type
      properties:
        action:
          type: string
          enum: [create-connection]
        workspaceId:
          type: string
          format: uuid
        type:
          type: string
          enum: [sftp, aws_s3]
        hostname:
          type: string
          description: sftp only, required for that type.
          example: sftp.example.com
        username:
          type: string
          description: sftp only. Optional, defaults to "sftpuser".
          example: sftpuser
        roleArn:
          type: string
          description: aws_s3 only, required for that type. The IAM role the customer will create/update.
          example: arn:aws:iam::123456789012:role/dpf-ingestion

    GetConnectionsRequest:
      type: object
      required:
        - action
        - workspaceId
      properties:
        action:
          type: string
          enum: [get-connections]
        workspaceId:
          type: string
          format: uuid

    TestConnectionRequest:
      type: object
      required:
        - action
        - workspaceId
        - connectionId
      properties:
        action:
          type: string
          enum: [test-connection]
        workspaceId:
          type: string
          format: uuid
        connectionId:
          type: string
          format: uuid

    DeleteConnectionRequest:
      type: object
      required:
        - action
        - workspaceId
        - connectionId
      properties:
        action:
          type: string
          enum: [delete-connection]
        workspaceId:
          type: string
          format: uuid
        connectionId:
          type: string
          format: uuid

    CreateConnectionResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          allOf:
            - $ref: '#/components/schemas/Connection'
            - type: object
              properties:
                message:
                  type: string
                trustPolicy:
                  type: object
                  description: aws_s3 only. Ready-to-paste IAM trust policy naming dpfPrincipalArn as principal, gated by externalId.
                  example:
                    Version: '2012-10-17'
                    Statement:
                      - Effect: Allow
                        Principal:
                          AWS: arn:aws:iam::442707444240:role/dpf-aws-connector
                        Action: sts:AssumeRole
                        Condition:
                          StringEquals:
                            sts:ExternalId: 3f9c2e1a-5b6d-4c7e-8f9a-0b1c2d3e4f5a

    GetConnectionsResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            connections:
              type: array
              items:
                $ref: '#/components/schemas/Connection'
            count:
              type: integer
              example: 1

    TestConnectionResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            connectionId:
              type: string
              format: uuid
            success:
              type: boolean
              description: Whether the connection test itself succeeded.
              example: true
            entryCount:
              type: integer
              description: sftp only. Number of directory entries returned (present on success).
              example: 3
            fileCount:
              type: integer
              description: sftp only. Number of regular files in the listing (present on success).
              example: 2
            account:
              type: string
              description: aws_s3 only. The AWS account ID of the assumed role (present on success).
              example: '123456789012'
            assumedRoleArn:
              type: string
              description: aws_s3 only. The full STS-assumed-role identity ARN (present on success).
              example: arn:aws:sts::123456789012:assumed-role/dpf-ingestion/dpf-test
            message:
              type: string
              example: Connected successfully. Listed 3 entries (2 files).

    DeleteConnectionResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            connectionId:
              type: string
              format: uuid
            message:
              type: string
              example: Connection deleted successfully

    TriggerFrequency:
      type: object
      description: |
        Polling cadence, evaluated in UTC by the hourly dispatcher.
        `hourOfDay` is required for `daily`/`monthly`; `dayOfMonth` for `monthly`.
      required:
        - unit
      properties:
        unit:
          type: string
          enum: [hourly, daily, monthly]
          example: daily
        hourOfDay:
          type: integer
          minimum: 0
          maximum: 23
          description: Hour of day (UTC). Required for daily and monthly.
          example: 6
        dayOfMonth:
          type: integer
          minimum: 1
          maximum: 31
          description: Day of month. Required for monthly.
          example: 1

    JobTrigger:
      type: object
      description: |
        A workspace ingestion trigger. `preCode`/`postCode` are the JavaScript
        generated from `preRules`/`postRules`; they are read-only (regenerated when
        the rules change) and are always included in responses.

        `spec_success` triggers fire when another spec's job completes successfully
        (`upstreamSpecId`/`upstreamSpecName`) instead of polling a connection — they
        have no `connectionId`/`frequency`/`preRules`/`postRules`/s3 fields. `schedule`
        triggers fire a spec directly on a plain cron-like schedule (`frequency`,
        same as sftp/aws_s3) with no connection and no upstream spec. Both
        `spec_success` and `schedule` require the trigger's own `specId`/`specName`
        (the spec fired) to be a table-source (`sourceType: 'tables'`) spec, since
        neither supplies a new file for a file-source spec to load — a table-source
        spec re-runs its generated query, windowed to rows since its last successful
        run, so both are just different ways to decide *when* to re-run it.
      properties:
        triggerId:
          type: string
          format: uuid
          example: 9c1e2d3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f
        workspaceId:
          type: string
          format: uuid
          example: 550e8400-e29b-41d4-a716-446655440000
        type:
          type: string
          enum: [sftp, aws_s3, spec_success, schedule]
          example: sftp
        specId:
          type: string
          format: uuid
          description: The (downstream) spec this trigger fires.
        specName:
          type: string
          example: web-logs
        connectionId:
          type: string
          format: uuid
          description: sftp/aws_s3 only.
          example: 7b3f8a10-1c2d-4e5f-9a0b-1c2d3e4f5a6b
        s3Bucket:
          type: string
          description: aws_s3 only, required for that type. The bucket this trigger polls.
          example: customer-data-bucket
        s3Prefix:
          type: string
          description: aws_s3 only. Optional key prefix; defaults to the whole bucket.
          example: exports/daily/
        upstreamSpecId:
          type: string
          format: uuid
          nullable: true
          description: spec_success only — the spec whose successful job completion fires this trigger.
        upstreamSpecName:
          type: string
          nullable: true
          example: daily-signups-load
          description: spec_success only — denormalized name of upstreamSpecId, for display.
        frequency:
          $ref: '#/components/schemas/TriggerFrequency'
        preRules:
          type: string
          description: Natural-language "which files to process" prompt (may be empty).
          example: Process only *.csv files under /outbound
        postRules:
          type: string
          description: Natural-language "what to do after upload" prompt (may be empty).
          example: Rename each processed file with a .done suffix
        preCode:
          type: string
          description: Generated body of async selectFiles(files, ctx). Read-only.
        postCode:
          type: string
          description: Generated body of async postProcess(sftp, files, ctx). Read-only.
        dedupe:
          type: boolean
          description: Skip files already loaded into this spec (per-spec, matched by file name).
          example: true
        enabled:
          type: boolean
          example: true
        createdBy:
          type: string
          format: uuid
        createdAt:
          type: integer
          format: int64
          description: Unix timestamp in milliseconds
        updatedAt:
          type: integer
          format: int64
          description: Unix timestamp in milliseconds
        lastRunAt:
          type: integer
          format: int64
          nullable: true
          description: Unix timestamp in milliseconds of the last run (absent if never run).
        lastRunStatus:
          type: string
          enum: [running, success, failed, no-files]
          nullable: true
        lastJobId:
          type: string
          format: uuid
          nullable: true
        warnings:
          type: array
          items:
            type: string
          description: >-
            create-trigger only, aws_s3 only. Advisory notes about the created trigger — currently just
            the 5000-object S3 listing cap. Absent on get/update/delete responses and on other types.
          example:
            - "Each run lists at most 5000 objects from this bucket/prefix, oldest key first. Once it
              holds more than 5000 objects, newly delivered files can fall outside that window and be
              missed entirely. Configure an S3 lifecycle rule to expire or transition older objects out
              of this prefix so it stays under the cap."

    JobTriggerRun:
      type: object
      description: A single trigger execution record.
      properties:
        triggerId:
          type: string
          format: uuid
        startedAt:
          type: string
          format: date-time
        workspaceId:
          type: string
          format: uuid
        status:
          type: string
          enum: [running, success, failed, no-files]
          example: success
        filesPulled:
          type: integer
          example: 3
        jobId:
          type: string
          format: uuid
          nullable: true
        message:
          type: string
          example: Loaded 3 file(s); job 9c1e2d3f started
        finishedAt:
          type: string
          format: date-time
          nullable: true
        specId:
          type: string
          format: uuid
          nullable: true
          description: Denormalized from the trigger at run-start time (absent for runs recorded before this field existed, or if the trigger couldn't be loaded)
        specName:
          type: string
          nullable: true
          example: web-logs
        connectionId:
          type: string
          format: uuid
          nullable: true

    CreateTriggerRequest:
      type: object
      description: |
        `connectionId`/`frequency` are required when `type` is `sftp`/`aws_s3` (the
        default). `upstreamSpecName` is required instead when `type` is
        `spec_success`. `frequency` is required instead when `type` is `schedule`
        (no connection or upstream spec). Both `spec_success` and `schedule` require
        the downstream spec (`specName`) to already be a table-source
        (`sourceType: 'tables'`) spec.
      required:
        - action
        - workspaceId
        - specName
      properties:
        action:
          type: string
          enum: [create-trigger]
        workspaceId:
          type: string
          format: uuid
        type:
          type: string
          enum: [sftp, aws_s3, spec_success, schedule]
          description: Defaults to "sftp". For sftp/aws_s3 must match the referenced connection's type.
        specName:
          type: string
          description: The (downstream) spec this trigger fires.
          example: web-logs
        connectionId:
          type: string
          format: uuid
          description: Required when type is "sftp" or "aws_s3".
        s3Bucket:
          type: string
          description: Required when type is "aws_s3". The bucket this trigger polls.
          example: customer-data-bucket
        s3Prefix:
          type: string
          description: Optional when type is "aws_s3"; defaults to the whole bucket. Ignored otherwise.
          example: exports/daily/
        frequency:
          allOf:
            - $ref: '#/components/schemas/TriggerFrequency'
          description: Required when type is "sftp", "aws_s3", or "schedule". Not applicable to spec_success.
        upstreamSpecName:
          type: string
          description: Required when type is "spec_success" — the spec whose successful job completion fires this trigger.
          example: daily-signups-load
        preRules:
          type: string
        postRules:
          type: string
        dedupe:
          type: boolean
          description: Defaults to false.
        enabled:
          type: boolean
          description: Defaults to true.

    GetTriggersRequest:
      type: object
      required:
        - action
        - workspaceId
      properties:
        action:
          type: string
          enum: [get-triggers]
        workspaceId:
          type: string
          format: uuid

    UpdateTriggerRequest:
      type: object
      description: |
        Partial update. Changing `preRules`, `postRules`, or `dedupe` regenerates
        `preCode`/`postCode`. Raw code is never accepted. `s3Bucket`/`s3Prefix` only apply
        to (and are only accepted for) an existing `aws_s3` trigger. `upstreamSpecName`
        only applies to (and is only accepted for) an existing `spec_success` trigger,
        and is re-validated for cycles just like create-trigger. `frequency` applies to
        sftp/aws_s3/schedule triggers. A trigger's `type` cannot be changed.
      required:
        - action
        - workspaceId
        - triggerId
      properties:
        action:
          type: string
          enum: [update-trigger]
        workspaceId:
          type: string
          format: uuid
        triggerId:
          type: string
          format: uuid
        frequency:
          $ref: '#/components/schemas/TriggerFrequency'
        preRules:
          type: string
        postRules:
          type: string
        dedupe:
          type: boolean
        enabled:
          type: boolean
        s3Bucket:
          type: string
          description: aws_s3 triggers only.
          example: customer-data-bucket
        s3Prefix:
          type: string
          description: aws_s3 triggers only.
          example: exports/daily/
        upstreamSpecName:
          type: string
          description: spec_success triggers only. Re-points the trigger at a different upstream spec.
          example: daily-signups-load

    DeleteTriggerRequest:
      type: object
      required:
        - action
        - workspaceId
        - triggerId
      properties:
        action:
          type: string
          enum: [delete-trigger]
        workspaceId:
          type: string
          format: uuid
        triggerId:
          type: string
          format: uuid

    RunTriggerNowRequest:
      type: object
      required:
        - action
        - workspaceId
        - triggerId
      properties:
        action:
          type: string
          enum: [run-trigger-now]
        workspaceId:
          type: string
          format: uuid
        triggerId:
          type: string
          format: uuid

    ClearProcessedFilesRequest:
      type: object
      required:
        - action
        - workspaceId
        - triggerId
      properties:
        action:
          type: string
          enum: [clear-processed-files]
        workspaceId:
          type: string
          format: uuid
        triggerId:
          type: string
          format: uuid

    CreateTriggerResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          $ref: '#/components/schemas/JobTrigger'

    GetTriggersResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            triggers:
              type: array
              items:
                $ref: '#/components/schemas/JobTrigger'
            count:
              type: integer
              example: 1

    UpdateTriggerResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          $ref: '#/components/schemas/JobTrigger'

    DeleteTriggerResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            triggerId:
              type: string
              format: uuid
            message:
              type: string
              example: Trigger deleted successfully

    RunTriggerNowResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            triggerId:
              type: string
              format: uuid
            message:
              type: string
              example: Trigger run started

    ClearProcessedFilesResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            triggerId:
              type: string
              format: uuid
            deleted:
              type: integer
              description: Number of processed-file ledger records deleted.
              example: 12
            message:
              type: string
              example: Cleared 12 processed file record(s)

    RegisterRequest:
      type: object
      required:
        - email
        - password
        - firstName
        - lastName
        - termsAccepted
      properties:
        email:
          type: string
          format: email
          description: User email address
          example: user@example.com
          maxLength: 255
        password:
          type: string
          description: User password (min 8 chars, max 128 chars)
          example: SecurePassword123
          minLength: 8
          maxLength: 128
        firstName:
          type: string
          description: User first name
          example: John
          minLength: 1
          maxLength: 100
        lastName:
          type: string
          description: User last name
          example: Doe
          minLength: 1
          maxLength: 100
        termsAccepted:
          type: boolean
          description: >-
            Affirmative acceptance of the Terms of Service and Privacy Policy.
            Must be true; registration is rejected (TERMS_NOT_ACCEPTED, 400)
            otherwise. The acceptance timestamp and document version are recorded
            on the user account.
          example: true

    RegisterResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            userId:
              type: string
              format: uuid
              description: Unique user identifier
              example: 550e8400-e29b-41d4-a716-446655440000
            email:
              type: string
              format: email
              example: user@example.com
            firstName:
              type: string
              example: John
            lastName:
              type: string
              example: Doe
            createdAt:
              type: string
              format: date-time
              example: '2026-05-31T12:34:56Z'

    VerifyOtpRequest:
      type: object
      required:
        - email
        - otp
      properties:
        email:
          type: string
          format: email
          description: Email address the account was registered with
          example: user@example.com
        otp:
          type: string
          description: One-time password from email (6 digits)
          example: '123456'
          pattern: '^\d{6}$'

    VerifyOtpResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            message:
              type: string
              example: Email verified successfully

    LoginRequest:
      type: object
      required:
        - email
        - password
      properties:
        email:
          type: string
          format: email
          description: User email address
          example: user@example.com
        password:
          type: string
          description: User password
          example: SecurePassword123

    LoginResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            userId:
              type: string
              format: uuid
              description: User unique identifier
              example: 550e8400-e29b-41d4-a716-446655440000
            email:
              type: string
              format: email
              description: User email address
              example: user@example.com
            firstName:
              type: string
              description: User first name
              example: John
            lastName:
              type: string
              description: User last name
              example: Doe
            token:
              type: string
              description: JWT access token for authentication (24h expiry)
              example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
            refreshToken:
              type: string
              description: |
                Opaque refresh token (~90-day sliding window). Exchange it at `/auth/refresh` for a
                new access token once the current one expires — rotates on every use, so store the
                returned refreshToken and discard the old one each time.
              example: q8c-BbPEpIF7ai-FrYUZ_a4acEk7finhMGHQ5vUvBvg

    RefreshTokenRequest:
      type: object
      required:
        - refreshToken
      properties:
        refreshToken:
          type: string
          description: Refresh token previously issued by /auth/login or a prior /auth/refresh call

    RefreshTokenResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          description: Same shape as LoginResponse.data, with a freshly-rotated token and refreshToken

    RequestPasswordResetRequest:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          format: email
          description: User email address
          example: user@example.com

    RequestPasswordResetResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            message:
              type: string
              example: If an account exists with this email, you will receive a password reset link.

    ResetPasswordRequest:
      type: object
      required:
        - email
        - otp
        - newPassword
      properties:
        email:
          type: string
          format: email
          description: User email address
          example: user@example.com
        otp:
          type: string
          description: One-time password from email (6 digits)
          example: '123456'
          pattern: '^\d{6}$'
        newPassword:
          type: string
          description: New password (min 8 chars, max 128 chars)
          example: NewPassword456
          minLength: 8
          maxLength: 128

    ResetPasswordResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            message:
              type: string
              example: Password has been reset successfully.

    GetSettingsResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            firstName:
              type: string
              example: Jane
            lastName:
              type: string
              example: Doe
            topOffEnabled:
              type: boolean
              description: Whether auto top-off (automatic credit purchase when balance is low) is active
              example: false

    UpdateSettingsRequest:
      type: object
      description: At least one field should be provided.
      properties:
        firstName:
          type: string
          example: Jane
        lastName:
          type: string
          example: Doe
        topOffEnabled:
          type: boolean
          example: true

    NotificationType:
      type: object
      description: A notification message type and how it is delivered by role.
      properties:
        messageType:
          type: string
          example: job.failed
        label:
          type: string
          example: Job failed
        category:
          type: string
          enum: [operational, billing]
          example: operational
        eligible:
          type: array
          description: Roles that may ever receive this type (hard gate).
          items:
            type: string
            enum: [owner, full, read-only]
          example: [owner, full, read-only]
        defaultOn:
          type: array
          description: Roles that receive this type with no explicit preference.
          items:
            type: string
            enum: [owner, full, read-only]
          example: [owner, full]

    NotificationPreference:
      type: object
      properties:
        workspaceId:
          type: string
          example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
        messageType:
          type: string
          example: job.succeeded
        enabled:
          type: boolean
          example: true
        channels:
          type: array
          items:
            type: string
          example: [email]
        updatedAt:
          type: string
          format: date-time
          example: "2026-07-05T00:00:00.000Z"

    ListNotificationPreferencesResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            types:
              type: array
              items:
                $ref: '#/components/schemas/NotificationType'
            preferences:
              type: array
              items:
                $ref: '#/components/schemas/NotificationPreference'

    SetNotificationPreferenceRequest:
      type: object
      required:
        - workspaceId
        - messageType
        - enabled
      properties:
        workspaceId:
          type: string
          example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
        messageType:
          type: string
          description: Must be a known notification message type.
          example: job.succeeded
        enabled:
          type: boolean
          example: true
        channels:
          type: array
          description: Optional. Defaults to ["email"].
          items:
            type: string
          example: [email]

    NotificationPreferenceResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          $ref: '#/components/schemas/NotificationPreference'

    GetBalanceResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            credits:
              type: number
              description: Current credit balance (can be negative)
              example: 742.5
            tier:
              type: string
              enum: [free, starter, pro]
              description: User's current pricing tier
              example: starter
            creditsPurchased:
              type: number
              description: Total credits allocated at start of billing month
              example: 1000
            creditsConsumed:
              type: number
              description: Total credits consumed this month (absolute value)
              example: 257.5
            topOffCount:
              type: integer
              description: Number of auto top-offs this month
              example: 0
            autoTopOffCredits:
              type: number
              description: Total credits added via auto top-off this month
              example: 0
            month:
              type: string
              description: Billing month (YYYY-MM format)
              example: "2026-06"
            hasPaymentMethod:
              type: boolean
              description: Whether the user has a saved Stripe payment method on file
              example: true
            renewsOn:
              type: string
              description: Date the current billing period renews (YYYY-MM-DD). Omitted if no billing dates are known yet.
              example: "2026-07-01"
            ownerUserId:
              type: string
              description: User ID of the workspace owner (only present when workspaceId was provided)
              example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
            topOffTriggered:
              type: boolean
              description: Present and true if an auto top-off was triggered during this request
              example: true

    GetBalanceRequest:
      type: object
      properties:
        action:
          type: string
          enum: [get-balance]
          description: Action identifier (optional, defaults to get-balance)
        workspaceId:
          type: string
          description: Optional workspace ID. When provided, returns the workspace owner's balance.

    PurchaseCreditsRequest:
      type: object
      required:
        - action
      properties:
        action:
          type: string
          enum: [purchase-credits]
        creditAmount:
          type: integer
          minimum: 100
          description: Number of credits to purchase. Must be an integer ≥ 100. Defaults to 100 if omitted.
          example: 100

    PurchaseCreditsResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            message:
              type: string
              description: Present if payment was charged off-session (saved payment method)
              example: "Credits purchased. They will appear shortly."
            url:
              type: string
              description: Present if user needs to complete payment via Stripe Checkout
              example: "https://checkout.stripe.com/c/pay/..."
            creditAmount:
              type: integer
              example: 100

    ModifySubscriptionRequest:
      type: object
      required:
        - action
        - newTier
      properties:
        action:
          type: string
          enum: [modify-subscription]
        newTier:
          type: string
          enum: [free, starter, pro]
          description: Target subscription tier
        couponCode:
          type: string
          description: Optional Stripe promotion code to apply to the subscription change.
          example: LAUNCH20

    ModifySubscriptionResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            message:
              type: string
              description: Human-readable result message
              example: "Upgraded to pro. Pro-rated credits have been added."
            url:
              type: string
              description: Present if the user needs to add a payment method to complete an upgrade — a Stripe Checkout URL
              example: "https://checkout.stripe.com/c/pay/..."
            newTier:
              type: string
              enum: [free, starter, pro]
            isUpgrade:
              type: boolean
              description: Whether the change was an upgrade (true) or downgrade (false)

    ManagePaymentRequest:
      type: object
      required:
        - action
      properties:
        action:
          type: string
          enum: [manage-payment]

    ManagePaymentResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            url:
              type: string
              description: Stripe Customer Portal URL for managing payment methods
              example: "https://billing.stripe.com/p/session/..."

    CreateCustomerResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            stripeCustomerId:
              type: string
              description: The user's Stripe customer ID (newly created or pre-existing)
              example: "cus_QabC1dEf2GhIjK"
            alreadyExists:
              type: boolean
              description: True if a Stripe customer already existed (HTTP 200); false if one was created (HTTP 201)
              example: false

    ErrorResponse:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              description: Machine-readable error code
              example: INVALID_EMAIL
            message:
              type: string
              description: Human-readable error message
              example: Invalid email format

    CreateWorkspaceRequest:
      title: Create Workspace
      type: object
      required:
        - action
        - name
      properties:
        action:
          type: string
          enum: [create]
          description: Action type - must be "create" to create a new workspace
          example: create
        name:
          type: string
          description: Workspace name
          example: My Data Processing Workspace
          minLength: 1
          maxLength: 255
        description:
          type: string
          description: Workspace description
          example: A workspace for processing customer data
          maxLength: 1000

    GrantPermissionRequest:
      title: Grant Permission
      type: object
      required:
        - action
        - workspaceId
        - userEmail
        - permission
      properties:
        action:
          type: string
          enum: [grant-permission]
          description: Action type - must be "grant-permission" to grant access to a user
          example: grant-permission
        workspaceId:
          type: string
          format: uuid
          description: Workspace ID to grant access to
          example: 550e8400-e29b-41d4-a716-446655440000
        userEmail:
          type: string
          format: email
          description: Email of user to grant permission to (handles registered and unregistered users)
          example: newuser@example.com
        permission:
          type: string
          enum: [full, read-only]
          description: Permission level to grant (full or read-only)
          example: read-only

    RevokePermissionRequest:
      title: Revoke Permission
      type: object
      required:
        - action
        - workspaceId
        - targetUserId
      properties:
        action:
          type: string
          enum: [revoke-permission]
          description: Action type - must be "revoke-permission" to revoke access from a user
          example: revoke-permission
        workspaceId:
          type: string
          format: uuid
          description: Workspace ID to revoke access from
          example: 550e8400-e29b-41d4-a716-446655440000
        targetUserId:
          type: string
          format: uuid
          description: User ID to revoke permission from
          example: 550e8400-e29b-41d4-a716-446655440002

    UpdateWorkspaceRequest:
      title: Update Workspace
      type: object
      required:
        - action
        - workspaceId
      properties:
        action:
          type: string
          enum: [update-workspace]
          description: Action type - must be "update-workspace" to update workspace details
          example: update-workspace
        workspaceId:
          type: string
          format: uuid
          description: Workspace ID to update
          example: 550e8400-e29b-41d4-a716-446655440000
        name:
          type: string
          description: New workspace name
          example: Updated Workspace Name
          minLength: 1
          maxLength: 255
        description:
          type: string
          description: New workspace description
          example: Updated description
          maxLength: 1000

    DeleteWorkspaceRequest:
      title: Delete Workspace
      type: object
      required:
        - action
        - workspaceId
      properties:
        action:
          type: string
          enum: [delete-workspace]
          description: Action type - must be "delete-workspace" to delete a workspace
          example: delete-workspace
        workspaceId:
          type: string
          format: uuid
          description: Workspace ID to delete
          example: 550e8400-e29b-41d4-a716-446655440000

    GetWorkspaceActionRequest:
      title: Get Workspace
      type: object
      required:
        - action
        - workspaceId
      properties:
        action:
          type: string
          enum: [get-workspace]
          description: Action type - must be "get-workspace" to retrieve workspace details
          example: get-workspace
        workspaceId:
          type: string
          format: uuid
          description: Workspace ID to retrieve
          example: 550e8400-e29b-41d4-a716-446655440000

    GetWorkspacesActionRequest:
      title: Get All Workspaces
      type: object
      required:
        - action
      properties:
        action:
          type: string
          enum: [get-workspaces]
          description: Action type - must be "get-workspaces" to list all user workspaces
          example: get-workspaces

    CreateWorkspaceResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            workspaceId:
              type: string
              format: uuid
              example: 550e8400-e29b-41d4-a716-446655440000
            name:
              type: string
              example: My Data Processing Workspace
            description:
              type: string
              example: A workspace for processing customer data
            createdBy:
              type: string
              format: uuid
              example: 550e8400-e29b-41d4-a716-446655440001
            createdAt:
              type: string
              format: date-time
              example: '2026-05-31T12:34:56Z'
            permission:
              type: string
              enum: [full, read-only]
              example: full
            permissions:
              type: array
              items:
                type: object
                properties:
                  userId:
                    type: string
                    format: uuid
                    example: 550e8400-e29b-41d4-a716-446655440001
                  email:
                    type: string
                    format: email
                    example: user@example.com
                  firstName:
                    type: string
                    nullable: true
                    description: User's first name (null for placeholder/unregistered users)
                    example: Jane
                  lastName:
                    type: string
                    nullable: true
                    description: User's last name (null for placeholder/unregistered users)
                    example: Doe
                  permission:
                    type: string
                    enum: [full, read-only]
                    example: full
              description: Array of user permissions for this workspace

    GetWorkspaceResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            workspaceId:
              type: string
              format: uuid
              example: 550e8400-e29b-41d4-a716-446655440000
            name:
              type: string
              example: My Data Processing Workspace
            description:
              type: string
              example: A workspace for processing customer data
            createdBy:
              type: string
              format: uuid
              example: 550e8400-e29b-41d4-a716-446655440001
            createdAt:
              type: string
              format: date-time
              example: '2026-05-31T12:34:56Z'
            userPermission:
              type: string
              enum: [full, read-only]
              example: full
            permissions:
              type: array
              items:
                type: object
                properties:
                  userId:
                    type: string
                    format: uuid
                    example: 550e8400-e29b-41d4-a716-446655440001
                  email:
                    type: string
                    format: email
                    example: user@example.com
                  firstName:
                    type: string
                    nullable: true
                    description: User's first name (null for placeholder/unregistered users)
                    example: Jane
                  lastName:
                    type: string
                    nullable: true
                    description: User's last name (null for placeholder/unregistered users)
                    example: Doe
                  permission:
                    type: string
                    enum: [full, read-only]
                    example: full
              description: Array of all user permissions for this workspace

    GrantPermissionResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            workspaceId:
              type: string
              format: uuid
              example: 550e8400-e29b-41d4-a716-446655440000
            userId:
              type: string
              format: uuid
              example: 550e8400-e29b-41d4-a716-446655440002
            email:
              type: string
              format: email
              description: Email of the user the permission was granted to
              example: newuser@example.com
            firstName:
              type: string
              nullable: true
              description: Granted user's first name (null for placeholder/unregistered users)
              example: Jane
            lastName:
              type: string
              nullable: true
              description: Granted user's last name (null for placeholder/unregistered users)
              example: Doe
            permission:
              type: string
              enum: [full, read-only]
              example: read-only
            grantedAt:
              type: string
              format: date-time
              example: '2026-05-31T12:34:56Z'
            grantedBy:
              type: string
              format: uuid
              example: 550e8400-e29b-41d4-a716-446655440001

    RevokePermissionResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            message:
              type: string
              example: Permission revoked successfully

    UpdateWorkspaceResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            workspaceId:
              type: string
              format: uuid
              example: 550e8400-e29b-41d4-a716-446655440000
            name:
              type: string
              example: Updated Workspace Name
            description:
              type: string
              example: Updated description
            updatedAt:
              type: string
              format: date-time
              example: '2026-05-31T12:34:56Z'

    DeleteWorkspaceResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            message:
              type: string
              example: Workspace deleted successfully

    GetWorkspacesResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            workspaces:
              type: array
              items:
                type: object
                properties:
                  workspaceId:
                    type: string
                    format: uuid
                    example: 550e8400-e29b-41d4-a716-446655440000
                  name:
                    type: string
                    example: My Data Processing Workspace
                  description:
                    type: string
                    example: A workspace for processing customer data
                  createdBy:
                    type: string
                    format: uuid
                    example: 550e8400-e29b-41d4-a716-446655440001
                  createdAt:
                    type: string
                    format: date-time
                    example: '2026-05-31T12:34:56Z'
                  userPermission:
                    type: string
                    enum: [full, read-only]
                    example: full
                  permissions:
                    type: array
                    items:
                      type: object
                      properties:
                        userId:
                          type: string
                          format: uuid
                          example: 550e8400-e29b-41d4-a716-446655440001
                        email:
                          type: string
                          format: email
                          example: user@example.com
                        firstName:
                          type: string
                          nullable: true
                          description: User's first name (null for placeholder/unregistered users)
                          example: Jane
                        lastName:
                          type: string
                          nullable: true
                          description: User's last name (null for placeholder/unregistered users)
                          example: Doe
                        permission:
                          type: string
                          enum: [full, read-only]
                          example: full
                    description: Array of all user permissions for this workspace
            count:
              type: integer
              description: Number of workspaces returned
              example: 3

    ListQueriesRequest:
      title: List Queries
      type: object
      required:
        - action
        - workspaceId
      properties:
        action:
          type: string
          enum: [list-queries]
          description: Action type
          example: list-queries
        workspaceId:
          type: string
          format: uuid
          description: Workspace to list query history for
          example: 550e8400-e29b-41d4-a716-446655440000
        pageSize:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
          description: Number of records per page
          example: 25
        cursor:
          type: string
          nullable: true
          description: Opaque pagination cursor from a prior response's `nextCursor` (omit for the first page)
        userId:
          type: string
          format: uuid
          nullable: true
          description: Filter to queries executed by a single user
          example: 550e8400-e29b-41d4-a716-446655440001
        search:
          type: string
          nullable: true
          description: Case-insensitive substring match on the query text
          example: select
        minDuration:
          type: integer
          nullable: true
          description: Keep only queries whose execution time (ms) is greater than or equal to this value
          example: 1000
        startTime:
          type: string
          format: date-time
          nullable: true
          description: Lower bound (inclusive) on the query execution time, ISO 8601
          example: '2026-06-01T00:00:00Z'
        endTime:
          type: string
          format: date-time
          nullable: true
          description: Upper bound (inclusive) on the query execution time, ISO 8601
          example: '2026-06-26T00:00:00Z'

    ListQueriesResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            queries:
              type: array
              description: One row per executed query, newest first
              items:
                type: object
                properties:
                  queryId:
                    type: string
                    format: uuid
                    example: 550e8400-e29b-41d4-a716-446655440042
                  userId:
                    type: string
                    format: uuid
                    description: User who executed the query
                    example: 550e8400-e29b-41d4-a716-446655440001
                  userEmail:
                    type: string
                    description: Email of the executing user (falls back to userId if unresolved)
                    example: analyst@example.com
                  namespace:
                    type: string
                    description: Catalog namespace the query ran against
                    example: 446655440000
                  querySize:
                    type: string
                    enum: [small, large]
                    description: Compute size the query ran on
                    example: small
                  durationMs:
                    type: integer
                    description: Actual query execution time in milliseconds
                    example: 1840
                  creditsCharged:
                    type: number
                    description: Credits charged for the query
                    example: 0.023
                  executedAt:
                    type: string
                    format: date-time
                    description: When the query was executed, ISO 8601
                    example: '2026-06-19T14:22:31Z'
                  queryText:
                    type: string
                    description: The SQL text that was executed
                    example: SELECT * FROM customers LIMIT 100
            users:
              type: array
              description: The workspace's members, for populating a user filter
              items:
                type: object
                properties:
                  userId:
                    type: string
                    format: uuid
                    example: 550e8400-e29b-41d4-a716-446655440001
                  email:
                    type: string
                    example: analyst@example.com
            pageSize:
              type: integer
              example: 25
            nextCursor:
              type: string
              nullable: true
              description: Opaque cursor for the next page; null when there are no more matching records
              example: eyJ3b3Jrc3BhY2VJZCI6eyJTIjoiNTUwZTg0MDAifX0=

    ListBytesAccessedRequest:
      title: List Bytes Accessed
      type: object
      required:
        - action
        - workspaceId
      properties:
        action:
          type: string
          enum: [list-bytes-accessed]
          description: Action type
          example: list-bytes-accessed
        workspaceId:
          type: string
          format: uuid
          description: Workspace to list bytes-accessed usage for
          example: 550e8400-e29b-41d4-a716-446655440000
        pageSize:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
          description: Number of records per page
          example: 25
        cursor:
          type: string
          nullable: true
          description: Opaque pagination cursor from a prior response's `nextCursor` (omit for the first page)
        clientId:
          type: string
          format: uuid
          nullable: true
          description: Filter to a single attributed catalog clientId
          example: 550e8400-e29b-41d4-a716-446655440099
        table:
          type: string
          nullable: true
          description: Filter to a single Iceberg table
          example: customers
        minBytesMB:
          type: number
          nullable: true
          description: Keep only records whose bytesAccessedMB is greater than or equal to this value
          example: 100
        startTime:
          type: string
          format: date-time
          nullable: true
          description: Lower bound (inclusive) on the log hour, ISO 8601
          example: '2026-06-01T00:00:00Z'
        endTime:
          type: string
          format: date-time
          nullable: true
          description: Upper bound (inclusive) on the log hour, ISO 8601
          example: '2026-06-26T00:00:00Z'

    ListBytesAccessedResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            records:
              type: array
              description: One row per (hour, attributed clientId, table), newest first
              items:
                type: object
                properties:
                  timestamp:
                    type: string
                    format: date-time
                    description: The hourly bucket (log hour) the usage was recorded in
                    example: '2026-06-19T14:00:00Z'
                  clientId:
                    type: string
                    format: uuid
                    description: API credential (see POST /oauth/clients) the reads were attributed to
                    example: 550e8400-e29b-41d4-a716-446655440099
                  clientLabel:
                    type: string
                    nullable: true
                    description: Name of the credential, resolved from POST /oauth/clients (null if the credential was revoked)
                    example: Iceberg Catalog Federation
                  tableName:
                    type: string
                    nullable: true
                    description: Iceberg table the reads hit (null for rows written before per-table attribution)
                    example: customers
                  bytesAccessedMB:
                    type: number
                    description: Megabytes read in the hour by this client/table
                    example: 245.67
                  requestCount:
                    type: integer
                    description: Number of S3 GET requests (reads)
                    example: 1820
                  creditsCharged:
                    type: number
                    description: Credits charged for this hour's reads
                    example: 0.737
            clients:
              type: array
              description: The workspace's catalog credentials, for populating a client-label filter
              items:
                type: object
                properties:
                  clientId:
                    type: string
                    format: uuid
                    example: 550e8400-e29b-41d4-a716-446655440099
                  label:
                    type: string
                    example: Iceberg Catalog Federation
            pageSize:
              type: integer
              example: 25
            nextCursor:
              type: string
              nullable: true
              description: Opaque cursor for the next page; null when there are no more matching records
              example: eyJ3b3Jrc3BhY2VJZCI6eyJTIjoiNTUwZTg0MDAifX0=

    ListStorageRequest:
      title: List Storage
      type: object
      required:
        - action
        - workspaceId
      properties:
        action:
          type: string
          enum: [list-storage]
          description: Action type
          example: list-storage
        workspaceId:
          type: string
          format: uuid
          description: Workspace to list storage usage for
          example: 550e8400-e29b-41d4-a716-446655440000
        pageSize:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
          description: Number of records per page
          example: 25
        cursor:
          type: string
          nullable: true
          description: Opaque pagination cursor from a prior response's `nextCursor` (omit for the first page)
        table:
          type: string
          nullable: true
          description: Filter to a single Iceberg table
          example: customers
        minStorageMB:
          type: number
          nullable: true
          description: Keep only records whose storageMB is greater than or equal to this value
          example: 100
        startTime:
          type: string
          format: date-time
          nullable: true
          description: Lower bound (inclusive) on the log hour, ISO 8601
          example: '2026-06-01T00:00:00Z'
        endTime:
          type: string
          format: date-time
          nullable: true
          description: Upper bound (inclusive) on the log hour, ISO 8601
          example: '2026-06-26T00:00:00Z'

    ListStorageResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            records:
              type: array
              description: One row per (hour, table), newest first
              items:
                type: object
                properties:
                  timestamp:
                    type: string
                    format: date-time
                    description: The hourly bucket (log hour) the usage was recorded in
                    example: '2026-06-19T14:00:00Z'
                  tableName:
                    type: string
                    nullable: true
                    description: Iceberg table the storage belongs to (null for rows written before per-table breakdown)
                    example: customers
                  storageMB:
                    type: number
                    description: Megabytes stored for this table in the hour
                    example: 1024.5
                  creditsCharged:
                    type: number
                    description: Credits charged for this table's storage in the hour
                    example: 1.0245
            pageSize:
              type: integer
              example: 25
            nextCursor:
              type: string
              nullable: true
              description: Opaque cursor for the next page; null when there are no more matching records
              example: eyJ3b3Jrc3BhY2VJZCI6eyJTIjoiNTUwZTg0MDAifX0=

    ListProcessedFilesRequest:
      type: object
      required:
        - action
        - workspaceId
      properties:
        action:
          type: string
          enum: [list-processed-files]
          description: Action type
          example: list-processed-files
        workspaceId:
          type: string
          format: uuid
          description: Workspace to list processed files for
          example: 550e8400-e29b-41d4-a716-446655440000
        pageSize:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
          description: Number of records per page
          example: 25
        cursor:
          type: string
          nullable: true
          description: Opaque pagination cursor from a prior response's `nextCursor` (omit for the first page)
        specId:
          type: string
          nullable: true
          description: Filter to a single data spec
        status:
          type: string
          nullable: true
          description: Filter to a processing status (e.g. processed)
          example: processed
        fileName:
          type: string
          nullable: true
          description: Keep only files whose name contains this substring (case-sensitive)
          example: sales
        dqOnly:
          type: boolean
          nullable: true
          description: Keep only files that have at least one data-quality issue
          example: true
        startTime:
          type: string
          format: date-time
          nullable: true
          description: Lower bound (inclusive) on when the file was processed, ISO 8601
          example: '2026-06-01T00:00:00Z'
        endTime:
          type: string
          format: date-time
          nullable: true
          description: Upper bound (inclusive) on when the file was processed, ISO 8601
          example: '2026-06-26T00:00:00Z'

    ListProcessedFilesResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            records:
              type: array
              description: One row per processed input file, newest first
              items:
                type: object
                properties:
                  processedAt:
                    type: string
                    format: date-time
                    description: When the file was processed
                    example: '2026-06-19T14:00:00Z'
                  fileName:
                    type: string
                    description: Source file name
                    example: sales_2026_06.csv
                  specId:
                    type: string
                    description: Spec the file was loaded under
                  specName:
                    type: string
                    nullable: true
                    description: Spec name at load time (denormalized onto the row)
                  jobId:
                    type: string
                    description: Data-load job that processed the file
                  rowsRead:
                    type: integer
                    description: Rows parsed from the file
                    example: 12045
                  status:
                    type: string
                    description: Processing status
                    example: processed
                  triggerId:
                    type: string
                    nullable: true
                    description: Present when the job was launched by a trigger; null for manual jobs
                  dqIssues:
                    type: array
                    description: >-
                      Legacy failures-only data-quality list, populated only on rows
                      written before per-check tracking existed (dqSchemaVersion null).
                      Each item has a `type` (source_column_mismatch,
                      source_dtype_mismatch, row_count_outlier, or
                      primary_key_duplicates_removed) and a human-readable `detail`,
                      plus type-specific fields. Empty on newer rows — use `dqChecks`
                      instead.
                    items:
                      type: object
                      additionalProperties: true
                  dqChecks:
                    type: array
                    description: >-
                      Per-file data-quality check RESULTS — one entry per (check type,
                      target table) relevant to the file, covering every outcome, not
                      just failures. Each item has a `type` (source_column_mismatch,
                      source_dtype_mismatch, row_count_outlier, or
                      primary_key_duplicates_removed), a `status` (`pass`, `fail`, or
                      `not_applicable` — e.g. the primary-key check is not_applicable
                      when the target table has no primary key defined), a
                      human-readable `detail`, plus type-specific fields (counts such
                      as expected/actual column counts, values checked/failed, rows
                      read vs. historical mean, or rows removed). Only populated on
                      rows with dqSchemaVersion set; empty on legacy rows.
                    items:
                      type: object
                      additionalProperties: true
                  dqFailCount:
                    type: integer
                    description: Count of dqChecks entries with status "fail". 0 on legacy rows.
                    example: 0
                  dqSchemaVersion:
                    type: integer
                    nullable: true
                    description: >-
                      Version marker for the dqChecks contract. Null on rows written
                      before per-check (pass/fail/not_applicable) tracking existed —
                      those rows only have the legacy `dqIssues` populated.
                    example: 2
            pageSize:
              type: integer
              example: 25
            nextCursor:
              type: string
              nullable: true
              description: Opaque cursor for the next page; null when there are no more matching records

    ListTriggerRunsRequest:
      title: List Trigger Runs
      type: object
      required:
        - action
        - workspaceId
      properties:
        action:
          type: string
          enum: [list-trigger-runs]
          description: Action type
          example: list-trigger-runs
        workspaceId:
          type: string
          format: uuid
          description: Workspace to list trigger-run history for, across all triggers
          example: 550e8400-e29b-41d4-a716-446655440000
        pageSize:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
          description: Number of records per page
          example: 25
        cursor:
          type: string
          nullable: true
          description: Opaque pagination cursor from a prior response's `nextCursor` (omit for the first page)
        specId:
          type: string
          format: uuid
          nullable: true
          description: Filter to runs of triggers feeding a single spec
        connectionId:
          type: string
          format: uuid
          nullable: true
          description: Filter to runs of triggers using a single connection
        startTime:
          type: string
          format: date-time
          nullable: true
          description: Lower bound (inclusive) on when the run started, ISO 8601
          example: '2026-06-01T00:00:00Z'
        endTime:
          type: string
          format: date-time
          nullable: true
          description: Upper bound (inclusive) on when the run started, ISO 8601
          example: '2026-06-26T00:00:00Z'

    ListTriggerRunsResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            runs:
              type: array
              description: One row per trigger execution, across all triggers in the workspace, newest first
              items:
                $ref: '#/components/schemas/JobTriggerRun'
            pageSize:
              type: integer
              example: 25
            nextCursor:
              type: string
              nullable: true
              description: Opaque cursor for the next page; null when there are no more matching records

    CreateSpecRequest:
      title: Create Spec
      type: object
      description: |
        `sourceType` defaults to `file`. When `file`, `sampleFileName` is required (as
        below). When `tables`, `sampleFileName`/`formatFileName` do not apply; instead
        `sourceTables` (the source table(s) to query) is required. The target is a
        generated SQL `INSERT`/`MERGE` (see `additionalPrompt`), executed via the query
        engine instead of a Glue job. See `start-analysis`.

        All three `targetOption` values work for both source types, and the target table
        rules are the same either way: for `sourceType: tables`, `existing-tables` fetches
        the named table's schema (requires `targetTables`, exactly one entry); `target-schema-file`
        creates it from an uploaded schema file; `auto-infer` has the AI design its schema
        (and the query producing it) together. For the latter two, `targetTables` is
        optional — omit it to let the target table's name be derived automatically (from
        the schema file, or AI-chosen), or pass exactly one entry to pin the name yourself.
      required:
        - action
        - workspaceId
        - specName
        - targetOption
      properties:
        action:
          type: string
          enum: [create-spec]
          description: 'Action type - must be "create-spec"'
          example: create-spec
        workspaceId:
          type: string
          format: uuid
          description: Workspace ID that owns this spec
          example: 550e8400-e29b-41d4-a716-446655440000
        specName:
          type: string
          description: Unique name for this data spec within the workspace
          example: Customer Data Load
          minLength: 1
          maxLength: 255
        sourceType:
          type: string
          enum: [file, tables]
          default: file
          description: |
            Where the data comes from.
            - **file**: upload a sample data file (default) — see `sampleFileName`.
            - **tables**: query existing Iceberg table(s) — see `sourceTables`. Runs via
              the query engine, not a Glue job.
          example: file
        sourceTables:
          type: array
          description: 'Required if sourceType=tables. Source table name(s) the generated query reads from.'
          items:
            type: string
          example: [customers]
        sampleFileName:
          type: string
          description: |
            Required if sourceType=file (default). Name of the sample data file to upload
            (e.g., data.csv, data.dat, data.parquet). This is the actual data that will be
            parsed and loaded into target tables. **Must be 100 KB or smaller** (representative
            sample only).
          example: customers.csv
          minLength: 1
          maxLength: 255
        formatFileName:
          type: string
          description: |
            Optional, sourceType=file only: Name of the format specification file to upload
            (e.g., schema.json, format.xls). This file describes how to parse the sample data
            (field positions, record types, etc.). If not provided, the AI will infer the
            format from the sample file. **Must be 100 KB or smaller.**
          example: schema.json
          minLength: 1
          maxLength: 255
          nullable: true
        targetOption:
          type: string
          enum: [existing-tables, target-schema-file, auto-infer]
          description: |
            How to determine the target schema (applies to both sourceType values):
            - **existing-tables**: Use table(s) already in the Iceberg catalog (requires targetTables).
            - **target-schema-file**: Provide a target schema file (requires targetSchemaFileName);
              the table is created from it.
            - **auto-infer**: AI infers the schema — from the sample + format files for
              sourceType=file, or from the source table(s) + additionalPrompt for
              sourceType=tables — and creates the table.
          example: auto-infer
        targetTables:
          type: array
          description: |
            Required if targetOption=existing-tables (array of target table names — sourceType=tables
            allows exactly one entry, the query's single target; sourceType=file allows one or more).
            Optional otherwise: for targetOption=target-schema-file/auto-infer the target table (and
            its name) is derived automatically — from the schema file, or AI-designed — for both
            sourceType values. Pass exactly one entry (sourceType=tables) to pin the name yourself
            instead of letting it be derived.
          items:
            type: string
          example: [customers, accounts]
        targetSchemaFileName:
          type: string
          description: |
            Required if targetOption=target-schema-file. Name of the target schema file to upload.
            Sent to AI to derive the target tables and field mapping. **Must be 100 KB or smaller.**
          example: target_schema.json
          minLength: 1
          maxLength: 255
        description:
          type: string
          description: Optional description of the data load spec
          example: Load customer data from CSV into Iceberg tables
          maxLength: 1000
        merge:
          type: boolean
          description: |
            Optional: Enable merge/upsert mode using primary keys instead of append.
            When sourceType=tables, also controls the generated SQL shape: MERGE when
            true, INSERT when false/omitted. When sourceType=file with targetOption
            existing-tables, the target table must already have a primary key set
            (400 MISSING_PRIMARY_KEY otherwise) — set one via the Iceberg REST endpoint
            (POST /iceberg/v1/namespaces/{namespace}/tables/{table}, set-properties on
            dpf.primary-keys). New tables (targetOption auto-infer/target-schema-file)
            have the AI choose one automatically when merge is true.
          example: false
        computeSize:
          type: string
          enum: [small, large]
          description: |
            Controls processing power for the job — the data load Glue job (sourceType=file)
            or credit-charging tier for the generated query (sourceType=tables).
            - **small**: 1/16 DPU (default, suitable for most workloads)
            - **large**: 1 DPU (for larger or more complex data transformations)
          example: small
          default: small
        additionalPrompt:
          type: string
          description: |
            Optional: Additional instructions for the AI. For sourceType=file: parsing,
            schema inference, or loading logic. For sourceType=tables: what the generated
            SQL query should compute from the source table(s).
          example: 'Parse the fixed-width file using positions defined in the format file'
          maxLength: 2000

    UpdateSpecRequest:
      title: Update Spec
      type: object
      description: |
        Updates an existing spec's mutable fields. Only fields present in the request are
        changed; omitted fields keep their current value. This only saves the settings — it does
        NOT regenerate code/SQL or re-run anything by itself. Call `start-analysis` afterward to
        do that (the workspace.html UI always does both together as "Update & Re-process").

        `sourceType` is fixed at creation and cannot be changed here. `sampleFileName`/
        `formatFileName` only apply to sourceType=file specs; `sourceTables`/`lastRunTs` only
        apply to sourceType=tables specs.
      required:
        - action
        - workspaceId
        - specName
      properties:
        action:
          type: string
          enum: [update-spec]
          description: 'Action type - must be "update-spec"'
          example: update-spec
        workspaceId:
          type: string
          format: uuid
          description: Workspace ID
          example: 550e8400-e29b-41d4-a716-446655440000
        specName:
          type: string
          description: Spec name (unique per workspace, from create-spec)
          example: Customer Data Load
        targetOption:
          type: string
          enum: [existing-tables, target-schema-file, auto-infer]
          description: Optional — change how the target schema is determined. See create-spec for what each value means.
        targetTables:
          type: array
          items:
            type: string
          description: Optional — new target table name(s). Exactly one entry for sourceType=tables.
          example: [customers, accounts]
        targetSchemaFileName:
          type: string
          description: Optional — new target schema file name (upload it via the returned signedUrls, and include "schemaFile" in changedFiles).
          example: target_schema.json
        sampleFileName:
          type: string
          description: Optional, sourceType=file only — new sample file name (upload via signedUrls; include "sampleFile" in changedFiles).
          example: customers_v2.csv
        formatFileName:
          type: string
          description: Optional, sourceType=file only — new format file name (upload via signedUrls; include "formatFile" in changedFiles).
          example: schema_v2.json
        sourceTables:
          type: array
          items:
            type: string
          description: Optional, sourceType=tables only — change the source table name(s) the generated query reads from.
          example: [customers]
        merge:
          type: boolean
          description: Optional — change merge/upsert mode.
          example: true
        computeSize:
          type: string
          enum: [small, large]
          description: Optional — change processing power. See create-spec for details.
          example: large
        additionalPrompt:
          type: string
          description: Optional — change the AI instructions used at the next start-analysis.
          maxLength: 2000
        description:
          type: string
          description: Optional — change the spec's description.
          maxLength: 1000
        lastRunTs:
          type: string
          format: date-time
          description: |
            Optional, sourceType=tables only. Overrides the watermark table_job.py uses as the
            `:last_run_ts` lower bound in the generated SQL (normally this is set automatically to
            the start ts of the spec's own last successful run). Set this to a past timestamp to
            force the next run to reprocess data from that point on — e.g. to backfill after a bug
            fix, or replay a window that was missed. Must be a valid ISO 8601 timestamp.
          example: '2026-01-01T00:00:00.000Z'
        changedFiles:
          type: array
          items:
            type: string
            enum: [sampleFile, formatFile, schemaFile]
          description: Which of sampleFileName/formatFileName/targetSchemaFileName are being replaced — only those get a presigned upload URL in the response.
          example: []

    StartAnalysisRequest:
      title: Start Analysis
      type: object
      required:
        - action
        - workspaceId
        - specName
      properties:
        action:
          type: string
          enum: [start-analysis]
          description: 'Action type - must be "start-analysis"'
          example: start-analysis
        workspaceId:
          type: string
          format: uuid
          description: Workspace ID
          example: 550e8400-e29b-41d4-a716-446655440000
        specName:
          type: string
          description: Spec name (unique per workspace, from create-spec)
          example: Customer Data Load
        loadSampleData:
          type: boolean
          default: true
          description: |
            When true (default), the pipeline stages the sample file into the job and triggers
            the Glue data-load job once schema inference and code generation finish — the
            existing end-to-end behavior. When false, the pipeline stops as soon as the
            transformation code is generated: no sample is loaded and no Glue job runs. Use
            this to review/validate the inferred schema and generated code without loading data.
          example: true

    GetStatusRequest:
      type: object
      title: Get Status by Job ID or Spec ID
      required:
        - action
      properties:
        action:
          type: string
          enum: [get-status]
          description: 'Action type - must be "get-status"'
          example: get-status
        jobId:
          type: string
          format: uuid
          description: |
            Job ID from create-job/start-job, or (for start-analysis runs with loadSampleData
            true) from the spec's lastJobId once the data load starts. Queries that specific
            data_jobs run — the Glue data-load execution. Provide either jobId or specId.
          example: 550e8400-e29b-41d4-a716-446655440005
        specId:
          type: string
          format: uuid
          description: |
            Spec ID (from create-spec or list-specs). Queries the spec's own process — schema
            inference and code generation, run by start-analysis — directly off the data_specs
            record. This is separate from any data_jobs run: status is "pending" until
            start-analysis is called, "processing" while it runs, then "ready" or "failed". Once
            "ready", the response's lastJobId (if start-analysis was run with loadSampleData
            true) points at the data_jobs record tracking the actual data load — poll that
            separately via jobId for load progress. Provide either jobId or specId.
          example: 550e8400-e29b-41d4-a716-446655440002

    CreateJobRequest:
      title: Create Job
      type: object
      required:
        - action
        - workspaceId
        - specName
      properties:
        action:
          type: string
          enum: [create-job]
          description: 'Action type - must be "create-job"'
          example: create-job
        workspaceId:
          type: string
          format: uuid
          description: Workspace ID
          example: 550e8400-e29b-41d4-a716-446655440000
        specName:
          type: string
          description: Spec name (must already exist from create-spec)
          example: Customer Data Load
        files:
          type: array
          items:
            type: string
          description: 'List of filenames to include in this job''s manifest. Required for sourceType=file; omit for sourceType=tables — there''s nothing to upload, so this just creates a bare job record.'
          example: [batch_001.csv, batch_002.csv, batch_003.csv]

    StartJobRequest:
      title: Start Job
      type: object
      required:
        - action
        - workspaceId
        - specName
        - jobId
      properties:
        action:
          type: string
          enum: [start-job]
          description: 'Action type - must be "start-job"'
          example: start-job
        workspaceId:
          type: string
          format: uuid
          description: Workspace ID
          example: 550e8400-e29b-41d4-a716-446655440000
        specName:
          type: string
          description: Spec name (must already have transformation code from start-analysis)
          example: Customer Data Load
        jobId:
          type: string
          format: uuid
          description: Job ID (from create-job response)
          example: 550e8400-e29b-41d4-a716-446655440005

    DeleteSpecRequest:
      title: Delete Spec
      type: object
      required:
        - action
        - workspaceId
        - specName
      properties:
        action:
          type: string
          enum: [delete-spec]
          description: 'Action type - must be "delete-spec"'
          example: delete-spec
        workspaceId:
          type: string
          format: uuid
          description: 'Workspace ID containing the spec to delete'
          example: 550e8400-e29b-41d4-a716-446655440000
        specName:
          type: string
          description: 'Name of the spec to delete'
          example: Customer Data Load
        deleteTargetTables:
          type: boolean
          description: |
            Optional flag to also delete target Iceberg tables. Default is `false`.
            - `false` (default): Delete spec and jobs, but preserve target tables and data
            - `true`: Delete spec, jobs, AND target Iceberg tables permanently
            
            **Warning**: When `true`, this permanently deletes all target tables and their data.
          example: false
          default: false

    CreateSpecResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          required:
            - specId
            - specName
            - uploadLocation
            - signedUrls
            - expiresIn
            - message
          properties:
            specId:
              type: string
              format: uuid
              description: Spec ID for use in subsequent operations
              example: 550e8400-e29b-41d4-a716-446655440003
            specName:
              type: string
              description: Name of the created spec
              example: Customer Data Load
            uploadLocation:
              type: string
              description: Canonical spec folder where the sample/format/schema files are stored
              example: s3://dpf-specs/550e8400-e29b-41d4-a716-446655440000/550e8400-e29b-41d4-a716-446655440003/
            signedUrls:
              type: object
              description: |
                Presigned URLs for uploading files (valid for 1 hour), keyed by filename.
                Each key is the filename provided in the request, each value is the presigned URL.
                
                **Important**: Files uploaded via these URLs must not exceed 100 KB (sample, format, and target schema files).
              additionalProperties:
                type: string
                format: uri
              example:
                customers.csv: https://dpf-specs.s3.us-east-1.amazonaws.com/...?X-Amz-Signature=...
                schema.json: https://dpf-specs.s3.us-east-1.amazonaws.com/...?X-Amz-Signature=...
            expiresIn:
              type: integer
              description: Presigned URL expiration time in seconds
              example: 3600
            message:
              type: string
              description: Instructions for next step
              example: Use the signed URLs to upload your files. Then call start-analysis to begin processing.

    UpdateSpecResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          required:
            - specId
            - specName
            - uploadLocation
            - signedUrls
            - expiresIn
            - merge
            - message
          properties:
            specId:
              type: string
              format: uuid
              example: 550e8400-e29b-41d4-a716-446655440003
            specName:
              type: string
              example: Customer Data Load
            uploadLocation:
              type: string
              description: Canonical spec folder where sample/format/schema files are stored
              example: s3://dpf-specs/550e8400-e29b-41d4-a716-446655440000/550e8400-e29b-41d4-a716-446655440003/
            signedUrls:
              type: object
              description: |
                Presigned upload URLs (valid for 1 hour) for any file named in changedFiles, keyed
                by filename. Empty if changedFiles was empty or omitted.
              additionalProperties:
                type: string
                format: uri
              example: {}
            expiresIn:
              type: integer
              example: 3600
            merge:
              type: boolean
              description: The spec's merge mode after this update
              example: false
            additionalPrompt:
              type: string
              nullable: true
              description: The spec's additionalPrompt after this update
            lastRunTs:
              type: string
              format: date-time
              nullable: true
              description: The spec's watermark after this update (sourceType=tables only; null otherwise or if never run)
              example: '2026-01-01T00:00:00.000Z'
            message:
              type: string
              description: Instructions for next step
              example: No file changes to upload. Call start-analysis to begin re-processing.

    StartAnalysisResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          required:
            - specId
            - specName
            - status
            - loadSampleData
            - message
          properties:
            specId:
              type: string
              format: uuid
              description: Spec ID
              example: 550e8400-e29b-41d4-a716-446655440003
            specName:
              type: string
              description: Name of the spec
              example: Customer Data Load
            status:
              type: string
              enum: [processing]
              description: Initial spec process status — the pipeline has just started
              example: processing
            loadSampleData:
              type: boolean
              description: Echoes whether the sample will be loaded and the Glue data-load job triggered once analysis finishes
              example: true
            message:
              type: string
              description: Status message
              example: Analysis started. Use get-status with specId to poll for updates; once the data load starts, its jobId appears there as lastJobId and can be polled separately.

    CreateJobResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          required:
            - jobId
            - specId
            - specName
            - message
          properties:
            jobId:
              type: string
              format: uuid
              description: Job ID for this new job
              example: 550e8400-e29b-41d4-a716-446655440006
            specId:
              type: string
              format: uuid
              description: Spec ID
              example: 550e8400-e29b-41d4-a716-446655440003
            specName:
              type: string
              description: Name of the spec
              example: Customer Data Load
            uploadLocation:
              type: string
              description: 'sourceType=file only. Storage location where files will be uploaded (job folder in the jobs bucket).'
              example: s3://dpf-jobs/550e8400-e29b-41d4-a716-446655440000/550e8400-e29b-41d4-a716-446655440003/550e8400-e29b-41d4-a716-446655440006/
            signedUrls:
              type: object
              description: 'sourceType=file only. Presigned URLs keyed by filename (valid for 1 hour).'
              additionalProperties:
                type: string
                format: uri
              example:
                batch_001.csv: https://dpf-jobs.s3.us-east-1.amazonaws.com/...?X-Amz-Signature=...
                batch_002.csv: https://dpf-jobs.s3.us-east-1.amazonaws.com/...?X-Amz-Signature=...
            expiresIn:
              type: integer
              description: 'sourceType=file only. Presigned URL expiration time in seconds.'
              example: 3600
            message:
              type: string
              description: Instructions for next step
              example: Use the signed URLs to upload your files. Then call start-job to trigger processing.

    StartJobResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          required:
            - jobId
            - specId
            - specName
            - status
            - message
          properties:
            jobId:
              type: string
              format: uuid
              description: Job ID
              example: 550e8400-e29b-41d4-a716-446655440006
            specId:
              type: string
              format: uuid
              description: Spec ID
              example: 550e8400-e29b-41d4-a716-446655440003
            specName:
              type: string
              description: Name of the spec
              example: Customer Data Load
            status:
              type: string
              enum: [processing]
              description: Job status after starting
              example: processing
            message:
              type: string
              description: Status message
              example: Data load job started. Use get-status with jobId to poll for updates.

    JobMetrics:
      type: object
      description: Job execution metrics (present when job is complete)
      properties:
        recordsRead:
          type: integer
          description: Total records read from all source files
          example: 10000
        recordsWritten:
          type: integer
          description: Total records written to all target tables
          example: 9998
        filesProcessed:
          type: integer
          description: Number of input files processed from manifest
          example: 1
        transformErrorCount:
          type: integer
          description: Number of transformation errors encountered
          example: 2
        dataQualityIssues:
          type: array
          description: List of data quality issues found during transformation
          items:
            type: string
          example: ['Row 5: missing required field id', 'Row 12: invalid date format']

    GetStatusByJobIdResponse:
      title: Get Status Response (queried by jobId)
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          description: The data_jobs record for this run — the Glue data-load execution.
          properties:
            jobId:
              type: string
              format: uuid
              example: 550e8400-e29b-41d4-a716-446655440005
            specId:
              type: string
              format: uuid
              description: Associated spec ID
              example: 550e8400-e29b-41d4-a716-446655440003
            workspaceId:
              type: string
              format: uuid
              description: Workspace ID
              example: 550e8400-e29b-41d4-a716-446655440000
            status:
              type: string
              enum: [ready, processing, complete, failed]
              description: Current job status
              example: processing
            currentLambda:
              type: string
              description: |
                Name of the pipeline stage currently processing (or last processed):
                - `prepare-transformation`: AI inference and code generation (only if start-analysis triggered this job before this refactor; current jobs are only touched by data-load)
                - `data-load`: Data transformation and writing to Iceberg tables
              example: data-load
              nullable: true
            statusMessage:
              type: string
              description: Human-readable status message
              example: Writing transformed data to Iceberg tables
            progress:
              type: integer
              description: Progress percentage (0-100)
              example: 45
              minimum: 0
              maximum: 100
            startTime:
              type: string
              format: date-time
              description: Job start time (null if not started yet)
              nullable: true
              example: '2026-05-31T12:34:56Z'
            endTime:
              type: string
              format: date-time
              description: Job end time (null if still running)
              nullable: true
              example: null
            durationMs:
              type: integer
              description: Job duration in milliseconds (null if still running)
              nullable: true
              example: null
            metrics:
              $ref: '#/components/schemas/JobMetrics'
              nullable: true
              description: Job execution metrics (only present when status is complete)
            error:
              type: string
              description: |
                Error code (only present if status=failed). Possible values:
                - `DATA_LOAD_INVOCATION_ERROR`: Failed to start data-load job
                - `INSUFFICIENT_CREDITS`: Workspace owner lacked credits to run the job
              nullable: true
              example: null
            errorDetails:
              type: string
              description: Human-readable error details (only present if status=failed)
              nullable: true
              example: null
            logLocation:
              type: string
              description: Path to job logs (for debugging)
              nullable: true
              example: s3://dpf-data/550e8400-e29b-41d4-a716-446655440000/550e8400-e29b-41d4-a716-446655440005/logs/job.log
            creditsCharged:
              type: number
              description: Total credits consumed by this job. Omitted for jobs run before credit tracking was introduced.
              nullable: true
              example: 12.5
            jobSize:
              type: string
              enum: [small, large]
              description: Compute tier used for the job. Omitted for jobs run before credit tracking was introduced.
              nullable: true
              example: small

    GetStatusBySpecIdResponse:
      title: Get Status Response (queried by specId)
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          description: |
            The data_specs record's own process status — schema inference and code generation,
            run by start-analysis. Independent of any data_jobs record.
          properties:
            specId:
              type: string
              format: uuid
              example: 550e8400-e29b-41d4-a716-446655440003
            workspaceId:
              type: string
              format: uuid
              description: Workspace ID
              example: 550e8400-e29b-41d4-a716-446655440000
            status:
              type: string
              enum: [pending, processing, ready, failed]
              description: |
                "pending": analysis never run. "processing": start-analysis is running.
                "ready": schema inference + code generation succeeded (transformationConfig is
                populated). "failed": the analysis pipeline itself failed — see error/errorDetails.
              example: processing
            statusMessage:
              type: string
              description: Human-readable status message
              example: Generating input file parsing code (AI)
            progress:
              type: integer
              description: Progress percentage (0-100)
              example: 45
              minimum: 0
              maximum: 100
            error:
              type: string
              description: |
                Error code (only present if status=failed). Possible values:
                - `FILE_TOO_LARGE`: Sample, format, or target schema file exceeds 100 KB
                - `SCHEMA_INFERENCE_FAILED`: AI could not determine target schema
                - `PARSE_CODE_GENERATION_FAILED`: AI failed to generate parsing code
                - `TRANSFORM_CODE_GENERATION_FAILED`: AI failed to generate transform code
                - `INVALID_SCHEMA`: Derived schema was structurally invalid
                - `JOB_STAGING_ERROR`: Failed to copy sample file into the job folder
                - `INSUFFICIENT_CREDITS`: Workspace owner lacked credits to start the data load
                - `DATA_LOAD_INVOCATION_ERROR`: Failed to start the Glue data-load job
                - `LAMBDA_INVOCATION_ERROR`: Failed to invoke prepare-transformation
                - `PREPARATION_TIMEOUT`: The pipeline did not finish before the Lambda timed out
                - `PREPARATION_ERROR`: General preparation failure
              nullable: true
              example: null
            errorDetails:
              type: string
              description: Human-readable error details (only present if status=failed)
              nullable: true
              example: null
            lastJobId:
              type: string
              format: uuid
              description: |
                The data_jobs record from the most recent start-analysis run with loadSampleData
                true (or from create-job/start-job). Null if no such job exists yet — including
                while a start-analysis run is still in its AI phase, since the job record is only
                created once preparation succeeds and the data load actually starts. Once status
                is "ready", poll this separately via get-status(jobId) for data-load progress.
              nullable: true
              example: 550e8400-e29b-41d4-a716-446655440005
            analysisStartTime:
              type: string
              format: date-time
              description: When the most recent analysis run started (null if never run)
              nullable: true
              example: '2026-05-31T12:34:56Z'
            analysisEndTime:
              type: string
              format: date-time
              description: When the most recent analysis run finished (null if still running or never run)
              nullable: true
              example: null
            analysisDurationMs:
              type: integer
              description: Duration of the most recent analysis run in milliseconds (null if still running or never run)
              nullable: true
              example: null
            hasTransformationConfig:
              type: boolean
              description: Whether the spec has generated parse/transform code
              example: true

    DeleteSpecResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          required:
            - specId
            - specName
            - retainedJobs
            - deletedS3Specs
            - deletedS3Data
            - deletedTargetTables
            - message
          properties:
            specId:
              type: string
              format: uuid
              description: ID of the deleted spec
              example: 550e8400-e29b-41d4-a716-446655440003
            specName:
              type: string
              description: Name of the deleted spec
              example: Customer Data Load
            retainedJobs:
              type: integer
              description: Number of data job records retained in the job log (marked with the deleted spec's name, rollback disabled; they expire via the job log's 90-day TTL)
              example: 3
            deletedS3Specs:
              type: integer
              description: Number of objects deleted from specs storage (sample, format, schema files)
              example: 3
            deletedS3Data:
              type: integer
              description: Number of objects deleted from data storage (Iceberg metadata and data files)
              example: 45
            deletedTargetTables:
              type: integer
              description: Number of target Iceberg tables deleted (only if deleteTargetTables=true)
              example: 0
            errors:
              type: array
              description: Any non-blocking errors encountered during cascade deletion
              items:
                type: object
                properties:
                  phase:
                    type: string
                    description: Deletion phase that encountered the error
                    enum: [specs_storage, data_storage, tables]
                    example: data_storage
                  error:
                    type: string
                    description: Error message
                    example: Access denied to storage bucket
                  tableName:
                    type: string
                    description: Table name (only for tables phase)
                    example: customers
            alreadyDeleted:
              type: boolean
              description: True if spec was already deleted (idempotent behavior)
              example: false
            message:
              type: string
              description: Summary message
              example: Spec deleted successfully (job history retained in the job log)

    ContactRequest:
      type: object
      required:
        - reason
        - message
      properties:
        reason:
          type: string
          enum:
            - request-a-demo
            - understand-licensing
            - report-an-issue
            - request-a-feature
          description: Reason for contacting
          example: request-a-demo
        message:
          type: string
          description: Message body (max 5000 characters)
          example: "We'd like to see a demo of the platform for our data team."
          maxLength: 5000
        email:
          type: string
          format: email
          description: |
            Contact email address.
            **Required** when unauthenticated (no Authorization header).
            **Ignored** when authenticated — the user's email is retrieved from their account.
          example: user@company.com
        name:
          type: string
          description: |
            Contact name.
            **Optional** when unauthenticated (used in the contact email if provided).
            **Ignored** when authenticated — the user's name is retrieved from their account.
          example: Jane Smith

    QueryRequest:
      type: object
      required:
        - namespace
        - query
      properties:
        namespace:
          type: string
          description: |
            Namespace to query (last 12 characters of workspaceId).
            Must be a namespace the authenticated user has access to.
          example: 633def9656c1
          pattern: '^[a-zA-Z0-9_]+$'
        query:
          type: string
          description: |
            SQL query to execute. Table references are unqualified (just the table name)
            since the namespace is pre-selected. Maximum 10,000 characters.
          example: SELECT * FROM detail LIMIT 10
          maxLength: 10000

    QueryResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            columns:
              type: array
              items:
                type: string
              description: Column names in the result set
              example: ["id", "name", "amount", "dpf_filename"]
            rows:
              type: array
              items:
                type: object
                additionalProperties: true
              description: Array of row objects (column name → value)
              example:
                - id: "CUST001"
                  name: "Acme Corp"
                  amount: 1500.00
                  dpf_filename: "customers.csv"
                - id: "CUST002"
                  name: "Beta Inc"
                  amount: 2300.50
                  dpf_filename: "customers.csv"
            rowCount:
              type: integer
              description: Number of rows returned
              example: 2
            executionTimeMs:
              type: integer
              description: Total execution time in milliseconds (includes setup)
              example: 1234

  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: JWT token obtained from login or registration

security: []
