openapi: 3.1.0
info:
  title: Flux API
  version: 1.1.0
  description: REST API for Flux boards, tasks, account workflows, files, organizations, admin, calendar, and agent automation.
servers:
  - url: http://localhost:8000
    description: Local development
  - url: https://fluxtask.org
    description: Canonical production
security:
  - bearerAuth: []
  - cookieAuth: []
tags:
  - name: Auth
  - name: Account
  - name: Boards
  - name: Members
  - name: Stages
  - name: Tasks
  - name: Workflows
  - name: Checklist
  - name: Files
  - name: Templates
  - name: Calendar
  - name: Admin
  - name: Public
  - name: Preview
  - name: Inbound
paths:
  /api/auth/register:
    post:
      tags: [Auth]
      summary: Register a user and start a session
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                name: { type: string }
                password: { type: string, minLength: 8 }
      responses:
        '201': { description: Registered, content: { application/json: { schema: { $ref: '#/components/schemas/UserResponse' } } } }
        '400': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/Error' }
  /api/auth/login:
    post:
      tags: [Auth]
      summary: Login and start a session
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string }
      responses:
        '200': { description: Logged in, content: { application/json: { schema: { $ref: '#/components/schemas/UserResponse' } } } }
        '401': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/Error' }
  /api/auth/logout:
    post:
      tags: [Auth]
      summary: Logout and clear the session cookie
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/auth/verify:
    post:
      tags: [Auth]
      summary: Confirm an email address from a verification link
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [token], properties: { token: { type: string } } } } }
      responses:
        '200': { description: Verified, content: { application/json: { schema: { $ref: '#/components/schemas/UserResponse' } } } }
        '400':
          description: >-
            Invalid, expired, or replayed link — the three are indistinguishable by
            design. Carries code EMAIL_VERIFICATION_REQUIRED so the link screen can
            offer an inline resend instead of a dead end.
          $ref: '#/components/responses/Error'
  /api/auth/verify/resend:
    post:
      tags: [Auth]
      summary: Re-send the current user's verification email
      description: >-
        When email delivery is not configured, marks an unverified account verified to
        match registration behavior. Inside the FLUX_VERIFY_RESEND_COOLDOWN_MS window
        nothing is sent and verification.status is cooldown with retryAfterMs; only a
        successful send arms that cooldown, so a failed one may be retried at once. An
        already-verified account returns verification.status already_verified — also
        without sending anything.
      responses:
        '200': { description: Resend outcome, content: { application/json: { schema: { $ref: '#/components/schemas/UserResponse' } } } }
        '401': { description: Authentication required }
        '502':
          description: The provider refused or timed out; the body still carries verification.
          $ref: '#/components/responses/Error'
  /api/auth/forgot:
    post:
      tags: [Auth]
      summary: Request a password-reset email
      description: Always returns 200 and never reveals whether an account exists for the address.
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [email], properties: { email: { type: string, format: email } } } } }
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/auth/reset:
    post:
      tags: [Auth]
      summary: Set a new password from a reset link
      description: Consumes the reset token, sets the password, and revokes all of the user's sessions.
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [token, password], properties: { token: { type: string }, password: { type: string, minLength: 8 } } } } }
      responses:
        '200': { $ref: '#/components/responses/Ok' }
        '400': { description: Invalid/expired link or password too short }
  /api/me:
    get:
      tags: [Account]
      summary: Get current user profile
      responses:
        '200': { description: Current user, content: { application/json: { schema: { $ref: '#/components/schemas/UserResponse' } } } }
        '401': { $ref: '#/components/responses/Error' }
    patch:
      tags: [Account]
      summary: Update current user's display name
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
      responses:
        '200': { description: Updated user, content: { application/json: { schema: { $ref: '#/components/schemas/UserResponse' } } } }
        '400': { $ref: '#/components/responses/Error' }
  /api/me/password:
    post:
      tags: [Account]
      summary: Change current user's password
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current, next]
              properties:
                current: { type: string }
                next: { type: string, minLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/Ok' }
        '403': { $ref: '#/components/responses/Error' }
  /api/accounts/{accountId}/workflows:
    parameters:
      - name: accountId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Workflows]
      summary: List active workflows visible to the caller
      responses:
        '200':
          description: Active workflow summaries
          content:
            application/json:
              schema:
                type: object
                properties:
                  workflows: { type: array, items: { $ref: '#/components/schemas/WorkflowSummary' } }
        '404': { $ref: '#/components/responses/Error' }
    post:
      tags: [Workflows]
      summary: Create an account workflow
      description: Account owner only. Omitting nodes creates a single start node. The JSON body is limited to 1 MiB.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorkflowDefinition' }
      responses:
        '201':
          description: Created workflow
          content:
            application/json:
              schema:
                type: object
                properties:
                  workflow: { $ref: '#/components/schemas/Workflow' }
        '400': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '503': { $ref: '#/components/responses/Error' }
  /api/accounts/{accountId}/workflows/{workflowId}:
    parameters:
      - name: accountId
        in: path
        required: true
        schema: { type: string }
      - name: workflowId
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Workflows]
      summary: Read a visible workflow definition
      responses:
        '200':
          description: Workflow definition
          content:
            application/json:
              schema:
                type: object
                properties:
                  workflow: { $ref: '#/components/schemas/Workflow' }
        '404': { $ref: '#/components/responses/Error' }
    put:
      tags: [Workflows]
      summary: Atomically replace a workflow definition
      description: Account owner only. ifVersion protects the complete definition replacement.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WorkflowDefinition'
                - type: object
                  required: [ifVersion]
                  properties:
                    ifVersion: { type: integer, minimum: 1 }
      responses:
        '200': { description: Updated workflow and live-task warnings }
        '400': { $ref: '#/components/responses/Error' }
        '409': { description: workflow_version_conflict with the current workflow }
        '503': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Workflows]
      summary: Soft-delete a workflow and mark live tasks
      responses:
        '200': { description: Deleted workflow and affectedTasks count }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '503': { $ref: '#/components/responses/Error' }
  /api/me/avatar:
    post:
      tags: [Account]
      summary: Upload current user's avatar image
      description: Accepts image uploads. SVG avatars are sanitized before storage.
      requestBody:
        required: true
        content:
          image/*:
            schema: { type: string, format: binary }
          application/octet-stream:
            schema: { type: string, format: binary }
      parameters:
        - $ref: '#/components/parameters/XFilename'
      responses:
        '200': { description: Updated user, content: { application/json: { schema: { $ref: '#/components/schemas/UserResponse' } } } }
        '400': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Account]
      summary: Delete current user's avatar
      responses:
        '200': { description: Updated user, content: { application/json: { schema: { $ref: '#/components/schemas/UserResponse' } } } }
  /api/me/prefs:
    put:
      tags: [Account]
      summary: Update current user's UI preferences
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Prefs' }
      responses:
        '200': { description: Updated prefs, content: { application/json: { schema: { type: object, properties: { prefs: { $ref: '#/components/schemas/Prefs' } } } } } }
        '409': { description: Stale whole-sidebar replacement, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /api/me/org/members:
    post:
      tags: [Account]
      summary: Add an email to the current org account's members
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '201': { description: Organization state, content: { application/json: { schema: { type: object, properties: { org: { $ref: '#/components/schemas/Org' } } } } } }
        '403': { $ref: '#/components/responses/Error' }
  /api/me/org/members/{email}:
    delete:
      tags: [Account]
      summary: Remove an email from the current org account's members
      parameters:
        - name: email
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { description: Organization state, content: { application/json: { schema: { type: object, properties: { org: { $ref: '#/components/schemas/Org' } } } } } }
  /api/me/org-memberships/{orgId}:
    delete:
      tags: [Account]
      summary: Leave an organization
      parameters:
        - $ref: '#/components/parameters/OrgId'
      responses:
        '200': { description: Updated user, content: { application/json: { schema: { $ref: '#/components/schemas/UserResponse' } } } }
  /api/users/{userId}/avatar:
    get:
      tags: [Account]
      summary: Get a user's avatar bytes
      parameters:
        - $ref: '#/components/parameters/UserId'
      responses:
        '200': { description: Avatar bytes }
        '404': { $ref: '#/components/responses/Error' }
  /api/tokens:
    get:
      tags: [Account]
      summary: List current user's API tokens
      responses:
        '200': { description: Tokens, content: { application/json: { schema: { type: object, properties: { tokens: { type: array, items: { $ref: '#/components/schemas/ApiToken' } } } } } } }
    post:
      tags: [Account]
      summary: Create an API token
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
      responses:
        '201': { description: Token plaintext returned once, content: { application/json: { schema: { type: object, properties: { id: { type: string }, name: { type: string }, token: { type: string } } } } } }
  /api/tokens/{tokenId}:
    delete:
      tags: [Account]
      summary: Delete an API token
      parameters:
        - name: tokenId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/billing:
    get:
      tags: [Account]
      summary: Current plan, subscription state, and purchasable plans
      responses:
        '200':
          description: Billing state
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean, description: Whether Stripe billing is configured on this server }
                  level: { $ref: '#/components/schemas/Level' }
                  subscribed: { type: boolean }
                  subscriptionStatus: { type: string, nullable: true }
                  plans:
                    type: array
                    items:
                      type: object
                      properties:
                        level: { type: string }
                        name: { type: string }
                        priceMonthly: { type: integer, description: USD cents per month }
  /api/billing/checkout:
    post:
      tags: [Account]
      summary: Start a Stripe Checkout session for a plan
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [level]
              properties:
                level: { type: string, enum: [starter, team, agent] }
      responses:
        '200': { description: Redirect target, content: { application/json: { schema: { type: object, properties: { url: { type: string } } } } } }
        '503': { description: Billing not configured }
  /api/billing/portal:
    post:
      tags: [Account]
      summary: Open the Stripe billing portal for the current user
      responses:
        '200': { description: Redirect target, content: { application/json: { schema: { type: object, properties: { url: { type: string } } } } } }
        '503': { description: Billing not configured }
  /api/billing/webhook:
    post:
      tags: [Account]
      summary: Stripe webhook receiver (signature-authenticated, not for browser clients)
      responses:
        '200': { description: Event accepted }
        '400': { description: Invalid signature }
  /api/templates:
    get:
      tags: [Templates]
      summary: List visible templates
      responses:
        '200': { description: Templates, content: { application/json: { schema: { type: object, properties: { templates: { type: array, items: { $ref: '#/components/schemas/Template' } } } } } } }
    post:
      tags: [Templates]
      summary: Save a board as a template
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [boardId, name]
              properties:
                boardId: { type: string }
                name: { type: string }
                scope: { type: string, enum: [personal, org], default: personal }
      responses:
        '201': { description: Template, content: { application/json: { schema: { type: object, properties: { template: { $ref: '#/components/schemas/Template' } } } } } }
        '422': { $ref: '#/components/responses/Error' }
  /api/templates/{templateId}:
    delete:
      tags: [Templates]
      summary: Delete a personal or org template
      parameters:
        - name: templateId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/boards:
    get:
      tags: [Boards]
      summary: List boards visible to current user
      parameters:
        - name: archived
          in: query
          schema: { type: string, enum: ['1'] }
          description: Include archived boards when set to 1.
      responses:
        '200': { description: Board summaries, content: { application/json: { schema: { type: object, properties: { boards: { type: array, items: { $ref: '#/components/schemas/BoardSummary' } } } } } } }
    post:
      tags: [Boards]
      summary: Create a board
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateBoard' }
      responses:
        '200': { description: Existing board (idempotent replay of a client-supplied id), content: { application/json: { schema: { $ref: '#/components/schemas/BoardResponse' } } } }
        '201': { description: Created board, content: { application/json: { schema: { $ref: '#/components/schemas/BoardResponse' } } } }
        '422': { $ref: '#/components/responses/Error' }
  /api/boards/{boardId}:
    get:
      tags: [Boards]
      summary: Get full board
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Full board, content: { application/json: { schema: { $ref: '#/components/schemas/BoardResponse' } } } }
    put:
      tags: [Boards]
      summary: Whole-board document sync
      description: >
        Stage defaultTaskTemplateId values are preserved when omitted and
        cleared only by explicit null.


        With If-Match this is an atomic compare-and-swap: the expected revision
        is re-checked against the live board inside the same transaction that
        performs the replacement, so a write committed after the caller's GET
        conflicts instead of being overwritten, and nothing is written on a
        conflict.


        This is a REPLACE: a stage the document omits is deleted, along with its
        tasks. Two omissions are refused rather than applied, both about live
        mail. Omitting a stage an inbound mail route points at would destroy the
        stage, the mail delivered into it, and the routing rule (which cascades
        off the stage), after which the next message recreates a same-named
        stage under a new id, leaving every recorded id for that stage
        permanently `STAGE_NOT_FOUND` (`code` `ROUTED_STAGE_MISSING`). Omitting
        a message delivered into a routed stage the document *keeps* deletes
        that message, and the delivery receipt outlives it, so the message stays
        recorded as delivered and redelivery is answered as a duplicate
        referring to nothing (`code` `ROUTED_TASK_MISSING`). Since a document is
        only as fresh as the GET it came from and mail can arrive in between,
        both are stale writes, and both answer `409` with the same `board`/`rev`
        a stale `If-Match` returns: refetch, merge, retry. The routed-task
        refusal covers delivered mail still in a routed stage only — hand-made
        tasks there, and mail already moved out, stay replaceable, and moving
        mail out carries the task rather than omitting it. A replacement the
        guard allows carries every delivery receipt across the rebuild and
        rebinds it to the recreated task, so the refusal does not weaken with
        repeated syncing and redelivery keeps resolving to the live task. To
        remove either deliberately, repoint the rule with
        `PUT /api/boards/{boardId}/inbound/routes/{routingKey}`, or use
        `DELETE /api/boards/{boardId}/stages/{stageId}` or
        `DELETE /api/boards/{boardId}/tasks/{taskId}`.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - name: If-Match
          in: header
          description: >-
            Expected board revision, as a bare decimal integer — exactly the form rev is
            returned in. Omit for last-write-wins compatibility; sending an empty value is
            NOT the same as omitting the header. The value is parsed, never numerically
            coerced, so anything else present is answered as a 409 conflict rather than a
            400: empty or whitespace-only, a sign (+1, -0), a leading zero (01), a fraction
            (1.0), another radix (0x2), exponent notation (2e0), an ETag (W/"3"), or two
            If-Match headers on one request. An expectation the server cannot parse is one
            it cannot honour, so it fails closed.
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BoardPut' }
      responses:
        '200': { description: Revision, content: { application/json: { schema: { type: object, properties: { rev: { type: integer } } } } } }
        '400': { description: 'Document rejected whole, before any mutation, e.g. BLOCKER_NOT_ON_BOARD — carries `location` naming the offending entry (tasks[3].blockedBy[0]). A cross-board edge already stored against that same task is retained rather than rejected; a same-board id is not grandfathered, so a document that deletes a task while another task still lists its id is rejected here too.', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { $ref: '#/components/responses/BoardWriteConflict' }
        '422': { $ref: '#/components/responses/Error' }
    patch:
      tags: [Boards]
      summary: Patch board metadata
      parameters:
        - $ref: '#/components/parameters/BoardId'
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BoardPatch' }
      responses:
        '200': { description: Board, content: { application/json: { schema: { type: object, properties: { board: { $ref: '#/components/schemas/Board' } } } } } }
    delete:
      tags: [Boards]
      summary: Delete a board
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/boards/{boardId}/rev:
    get:
      tags: [Boards]
      summary: Get board revision
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Revision, content: { application/json: { schema: { type: object, properties: { rev: { type: integer } } } } } }
  /api/boards/{boardId}/backup:
    get:
      tags: [Boards]
      summary: Export organization-owned board backup
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Backup, content: { application/json: { schema: { type: object, properties: { backup: { $ref: '#/components/schemas/BoardBackup' } } } } } }
  /api/boards/{boardId}/export:
    get:
      tags: [Boards]
      summary: Export a portable board document, including checksummed task notes
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Portable board, content: { application/json: { schema: { type: object, properties: { board: { $ref: '#/components/schemas/Board' } } } } } }
  /api/boards/{boardId}/restore:
    post:
      tags: [Boards]
      summary: Restore organization-owned board backup
      description: >-
        Reaches the same whole-board replace as `PUT /api/boards/{boardId}`, so
        it inherits that endpoint's mail guards and answers them in the same
        shape, `board` and `rev` included: a backup taken before a domain's
        stage existed, or before its latest mail arrived, cannot silently undo
        the mail delivered since. The destination is left untouched by a
        refusal.
      parameters:
        - $ref: '#/components/parameters/BoardId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BoardBackup' }
      responses:
        '200': { description: Restored board, content: { application/json: { schema: { type: object, properties: { board: { $ref: '#/components/schemas/Board' } } } } } }
        '400': { description: 'Backup rejected whole, before any mutation, e.g. BLOCKER_NOT_ON_BOARD — carries `location` naming the offending entry (tasks[3].blockedBy[0]). A cross-board edge already stored against that same task is retained, so a backup of a board holding a legacy cross-board edge still restores; any other cross-board id is refused, and a same-board id whose task the backup omits is rejected rather than grandfathered.', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { $ref: '#/components/responses/BoardWriteConflict' }
        '422': { $ref: '#/components/responses/Error' }
  /api/boards/{boardId}/pop:
    post:
      tags: [Boards]
      summary: Select and lock the top available task for agents
      description: >-
        Board-wide compatibility route for agents processing a queue efficiently.
        Prefer the stage-scoped pop route when working a specific stage. The
        endpoint selects the highest-priority available task that is not done,
        blocked, or claimed, then claims it before returning it. Claims expire:
        pass `holder` to identify the claimant and `ttlSeconds` to set the lease
        (default 3600, maximum 86400). A claim whose deadline has passed is
        released automatically by the next pop, so an agent that dies mid-task
        does not hide it from the queue forever. Pass `capabilities` to skip
        tasks whose `needs:*` tags the caller cannot satisfy. A task tagged
        `umbrella` is never returned — it tracks other tasks rather than
        describing work — though it stays visible in stage and task reads and
        can still be taken by id through the claim route. When processing is
        complete, call the task unlock endpoint to release the claim.


        Pass `stageIds` to drain several stages as one queue. Calling the
        stage-scoped pop once per stage instead ranks by stage before priority,
        so a low-priority card in the first stage is always claimed ahead of a
        high-priority card in the second. With `stageIds` only the listed stages
        are eligible, and within them selection is ranked by priority first,
        canonical board stage order second, task order third — the order the
        ids are listed in does not affect the tie-break. The selected task keeps
        its own stage; pop selects across stages but never moves the card. This
        field is accepted on this board-wide route only.
      parameters:
        - $ref: '#/components/parameters/BoardId'
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BoardPopRequest' }
      responses:
        '200': { description: 'Task (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '400': { description: 'Body contains an unsupported field, or a malformed `stageIds` (not an array, empty, an empty/non-string entry, an explicit null, or more than 100 entries). Rejected rather than ignored: a silently dropped allowed-stage list drains stages the caller never asked for.' }
        '404': { description: 'Nothing available (NO_AVAILABLE_TASK) — the ordinary end of a drain, not an error — or a `stageIds` entry that is not a stage of this board (STAGE_NOT_FOUND). Distinguished by the body `code`, never by the message.' }
  /api/boards/{boardId}/members:
    get:
      tags: [Members]
      summary: List board members
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Members, content: { application/json: { schema: { type: object, properties: { members: { type: array, items: { $ref: '#/components/schemas/Member' } } } } } } }
    post:
      tags: [Members]
      summary: Add a board member
      parameters:
        - $ref: '#/components/parameters/BoardId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                role: { type: string, enum: [viewer, editor], default: editor }
      responses:
        '200': { description: Members, content: { application/json: { schema: { type: object, properties: { members: { type: array, items: { $ref: '#/components/schemas/Member' } } } } } } }
  /api/boards/{boardId}/members/pending:
    get:
      tags: [Members]
      summary: List pending (not yet mailbox-verified) board invites — owner/admin only
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Pending invites, content: { application/json: { schema: { type: object, properties: { invites: { type: array, items: { type: object, properties: { email: { type: string }, role: { type: string, enum: [viewer, editor] }, addedAt: { type: integer } } } } } } } } }
        '403': { description: Owner access required }
  /api/boards/{boardId}/members/pending/{email}:
    delete:
      tags: [Members]
      summary: Cancel a pending board invite by email — owner/admin only, idempotent
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - name: email
          in: path
          required: true
          schema: { type: string, format: email }
      responses:
        '200': { description: Members, content: { application/json: { schema: { type: object, properties: { members: { type: array, items: { $ref: '#/components/schemas/Member' } } } } } } }
        '403': { description: Owner access required }
  /api/boards/{boardId}/members/{userId}:
    patch:
      tags: [Members]
      summary: Change a member role
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/UserId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                role: { type: string, enum: [viewer, editor] }
      responses:
        '200': { description: Members, content: { application/json: { schema: { type: object, properties: { members: { type: array, items: { $ref: '#/components/schemas/Member' } } } } } } }
    delete:
      tags: [Members]
      summary: Remove or leave board membership
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/UserId'
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/boards/{boardId}/webhooks:
    get:
      tags: [Webhooks]
      summary: List board webhooks
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Webhooks, content: { application/json: { schema: { type: object, properties: { webhooks: { type: array, items: { $ref: '#/components/schemas/Webhook' } } } } } } }
    post:
      tags: [Webhooks]
      summary: Create a webhook
      parameters:
        - $ref: '#/components/parameters/BoardId'
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPatch' }
      responses:
        '201': { description: Webhook with one-time secret, content: { application/json: { schema: { type: object, properties: { webhook: { $ref: '#/components/schemas/Webhook' } } } } } }
  /api/boards/{boardId}/webhooks/{webhookId}:
    patch:
      tags: [Webhooks]
      summary: Patch a webhook
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - name: webhookId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPatch' }
      responses:
        '200': { description: Webhook, content: { application/json: { schema: { type: object, properties: { webhook: { $ref: '#/components/schemas/Webhook' } } } } } }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - name: webhookId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/boards/{boardId}/webhook-deliveries:
    get:
      tags: [Webhooks]
      summary: List webhook deliveries
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - name: webhookId
          in: query
          schema: { type: string }
      responses:
        '200': { description: Deliveries, content: { application/json: { schema: { type: object, properties: { deliveries: { type: array, items: { $ref: '#/components/schemas/WebhookDelivery' } } } } } } }
  /api/boards/{boardId}/inbound:
    get:
      tags: [Inbound]
      summary: Read masked organization inbound-mail configuration
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Masked config, content: { application/json: { schema: { type: object, properties: { inbound: { $ref: '#/components/schemas/InboundConfig' } } } } } }
        '403': { $ref: '#/components/responses/Error' }
        '503': { $ref: '#/components/responses/Error' }
    put:
      tags: [Inbound]
      summary: Configure organization inbound mail
      parameters:
        - $ref: '#/components/parameters/BoardId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InboundPatch' }
      responses:
        '200': { description: Masked config, content: { application/json: { schema: { type: object, properties: { inbound: { $ref: '#/components/schemas/InboundConfig' } } } } } }
        '403': { $ref: '#/components/responses/Error' }
        '503': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Inbound]
      summary: Remove inbound configuration and delivery records
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/boards/{boardId}/inbound/rotate:
    post:
      tags: [Inbound]
      summary: Rotate the opaque endpoint or signing secret
      parameters:
        - $ref: '#/components/parameters/BoardId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [what]
              properties:
                what: { type: string, enum: [endpoint, secret] }
                signingSecret: { type: string, writeOnly: true, description: Provider-generated Svix secret; required when what is secret }
      responses:
        '200': { description: Rotated config; the previous endpoint or signing secret remains accepted for 24 hours, content: { application/json: { schema: { type: object, properties: { inbound: { $ref: '#/components/schemas/InboundConfig' } } } } } }
  /api/boards/{boardId}/inbound/reseal:
    post:
      tags: [Inbound]
      summary: Re-seal all stored inbound credentials with the active encryption key
      description: Board-owner operation bounded to the configured board's two or three ciphertexts. The update is transactional; INBOUND_RESEAL_FAILED changes nothing.
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200':
          description: All stored credentials were re-sealed
          content:
            application/json:
              schema:
                type: object
                required: [ok, activeKeyId, resealed]
                properties:
                  ok: { type: boolean }
                  activeKeyId: { type: string }
                  resealed: { type: integer, minimum: 2, maximum: 3 }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
  /api/boards/{boardId}/inbound/jobs:
    get:
      tags: [Inbound]
      summary: List inbound delivery and dead-letter jobs
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Jobs with redacted errors, content: { application/json: { schema: { type: object, properties: { jobs: { type: array, items: { $ref: '#/components/schemas/InboundJob' } } } } } } }
  /api/boards/{boardId}/inbound/routes:
    get:
      tags: [Inbound]
      summary: Read the inbound routing table for a board
      description: >-
        Which recipient lands in which stage, with the context needed to spot a misrouted
        domain without database access: whether the routed stage is the one named for the
        routing key, whether its name is exactly the shadow name Flux generates on a name
        collision, task counts against the delivery cap, webhook references, and every group
        of stages colliding on lower(name). The report also exposes domain-shaped stages not
        targeted by a route for the same normalized domain; each is drift requiring an explicit
        PUT decision, never permission to infer a route or send test mail. Each route carries a
        kind: address routes match one exact recipient and are consulted first, domain routes
        are the fallback for every unmapped local part. Board-owner only; a caller with no role
        on the board receives 404.
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Routing table, content: { application/json: { schema: { $ref: '#/components/schemas/InboundRoutesReport' } } } }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '503': { $ref: '#/components/responses/Error' }
  /api/boards/{boardId}/inbound/routes/{routingKey}:
    put:
      tags: [Inbound]
      summary: Point a routing key at a stage
      description: >-
        Creates the mapping when the routing key has never received mail (201), otherwise
        repoints it (200). The key may be an exact recipient address ("ghostpace@fluxtask.org")
        or a bare domain ("fluxtask.org"); it is normalized exactly as the delivery path
        derives it, so it is lowercased, IDN-folded, and any "+tag" is stripped. An address
        route is matched before the domain route, leaving every unmapped local part on the
        domain route. moveTasks carries only the backlog that did not provably arrive through
        another route and fails 409 rather than merging onto the delivery cap. dropSourceStage
        deletes the stage the route left only when it is an emptied, unrouted, webhook-free
        generated shadow — never for an address key, which has no generated shadow name;
        otherwise previousStage.keptBecause explains why it was kept.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/RoutingKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [stageId]
              properties:
                stageId: { type: string, description: A stage on this board }
                moveTasks: { type: boolean, default: false }
                dropSourceStage: { type: boolean, default: false }
      responses:
        '200': { description: Routing key repointed, content: { application/json: { schema: { $ref: '#/components/schemas/InboundRouteChange' } } } }
        '201': { description: Routing key created, content: { application/json: { schema: { $ref: '#/components/schemas/InboundRouteChange' } } } }
        '400': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { description: Merging the backlog would reach the per-stage delivery cap, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '503': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Inbound]
      summary: Remove a routing key
      description: >-
        Removes the mapping only. The stage, its tasks and the delivery receipts are untouched;
        the next message for the domain re-adopts the stage named for it.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/RoutingKey'
      responses:
        '200': { $ref: '#/components/responses/Ok' }
        '400': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '503': { $ref: '#/components/responses/Error' }
  /api/inbound/{endpointId}:
    post:
      tags: [Inbound]
      summary: Receive a Resend email.received event
      description: Signature-authenticated public endpoint. Verification uses the exact raw JSON body and Svix's five-minute timestamp tolerance.
      security: []
      parameters:
        - name: endpointId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - { name: svix-id, in: header, required: true, schema: { type: string } }
        - { name: svix-timestamp, in: header, required: true, schema: { type: string } }
        - { name: svix-signature, in: header, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        '200': { description: Queued, duplicate, or ignored }
        '400': { $ref: '#/components/responses/Error' }
        '429': { description: Per-endpoint rate or durable queue capacity exceeded, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /api/boards/{boardId}/stages:
    get:
      tags: [Stages]
      summary: List stages
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Stages, content: { application/json: { schema: { type: object, properties: { stages: { type: array, items: { $ref: '#/components/schemas/Stage' } } } } } } }
    post:
      tags: [Stages]
      summary: Create a stage
      parameters:
        - $ref: '#/components/parameters/BoardId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { $ref: '#/components/schemas/ClientId' }
                name: { type: string }
                color: { type: string }
                description: { type: string }
                url: { type: string }
                headerImage: { type: string }
                headerCrop: { type: string, description: "Image crop position such as '50% 50%'" }
                terminal: { type: boolean, description: "Stage holds completed work: tasks moved in are marked done (releasing anything they block); tasks moved out are reopened" }
      responses:
        '200': { description: Existing stage (idempotent replay of a client-supplied id), content: { application/json: { schema: { type: object, properties: { stage: { $ref: '#/components/schemas/Stage' } } } } } }
        '201': { description: Stage, content: { application/json: { schema: { type: object, properties: { stage: { $ref: '#/components/schemas/Stage' } } } } } }
  /api/boards/{boardId}/stages/{stageId}:
    patch:
      tags: [Stages]
      summary: Patch a stage
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/StageId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                color: { type: string }
                description: { type: string }
                url: { type: string }
                headerImage: { type: string }
                headerCrop: { type: string, description: "Image crop position such as '50% 50%'" }
                terminal: { type: boolean, description: "Stage holds completed work: tasks moved in are marked done (releasing anything they block); tasks moved out are reopened" }
                order: { type: number }
      responses:
        '200': { description: Stage, content: { application/json: { schema: { type: object, properties: { stage: { $ref: '#/components/schemas/Stage' } } } } } }
    delete:
      tags: [Stages]
      summary: Delete a stage and its tasks
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/StageId'
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/boards/{boardId}/stages/{stageId}/tasks:
    get:
      tags: [Stages]
      summary: List ordered tasks in a stage
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/StageId'
      responses:
        '200': { description: 'Tasks (bare entries under tasks)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskListEnvelope' } } } }
        '404': { description: 'No such stage on this board (STAGE_NOT_FOUND)' }
  /api/boards/{boardId}/stages/{stageId}/pop:
    post:
      tags: [Stages]
      summary: Select and lock the top available task in a stage
      description: >-
        Recommended for agents processing a stage queue. Selects the
        highest-priority task in this stage that is not done, blocked, already
        tagged `locked`, or tagged `umbrella`, adds the cooperative `locked`
        tag, and returns it. When processing is complete or abandoned, call the
        task unlock endpoint.


        Both empty-handed outcomes are `404`, so branch on the body's `code`,
        never on the message: `NO_AVAILABLE_TASK` means this stage exists and
        has nothing to hand out (move to the next stage), `STAGE_NOT_FOUND`
        means this board has no such stage (the stage id is stale — re-read the
        board's stages). The two are otherwise indistinguishable, which is what
        makes a stage that is being created or dropped concurrently look like a
        server that cannot make up its mind.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/StageId'
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PopRequest' }
      responses:
        '200': { description: 'Task (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '400': { description: Body contains an unsupported field }
        '404': { description: 'Empty stage (NO_AVAILABLE_TASK) or no such stage (STAGE_NOT_FOUND) — distinguished by the body `code`' }
  /api/boards/{boardId}/stage/{stageId}/tasks:
    get:
      tags: [Stages]
      summary: List ordered tasks in a stage (singular alias)
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/StageId'
      responses:
        '200': { description: 'Tasks (bare entries under tasks)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskListEnvelope' } } } }
        '404': { description: 'No such stage on this board (STAGE_NOT_FOUND)' }
  /api/boards/{boardId}/stage/{stageId}/pop:
    post:
      tags: [Stages]
      summary: Select and lock the top available task in a stage (singular alias)
      description: Singular alias for the stage-scoped pop route, including its `code`-carrying 404s.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/StageId'
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PopRequest' }
      responses:
        '200': { description: 'Task (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '400': { description: Body contains an unsupported field }
        '404': { description: 'Empty stage (NO_AVAILABLE_TASK) or no such stage (STAGE_NOT_FOUND) — distinguished by the body `code`' }
  /api/boards/{boardId}/task-templates:
    get:
      tags: [Tasks]
      summary: List board-scoped task templates
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Task templates, content: { application/json: { schema: { type: object, properties: { taskTemplates: { type: array, items: { $ref: '#/components/schemas/TaskTemplate' } } } } } } }
    post:
      tags: [Tasks]
      summary: Save task-editor values as a board-scoped template
      parameters:
        - $ref: '#/components/parameters/BoardId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, task]
              properties:
                name: { type: string }
                task: { $ref: '#/components/schemas/TaskPatch' }
      responses:
        '201': { description: Task template, content: { application/json: { schema: { type: object, properties: { taskTemplate: { $ref: '#/components/schemas/TaskTemplate' } } } } } }
        '422': { $ref: '#/components/responses/Error' }
  /api/boards/{boardId}/task-templates/{templateId}:
    delete:
      tags: [Tasks]
      summary: Delete a board-scoped task template
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - name: templateId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/boards/{boardId}/tasks:
    get:
      tags: [Tasks]
      summary: List tasks
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: 'Tasks (bare entries under tasks)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskListEnvelope' } } } }
    post:
      tags: [Tasks]
      summary: Create a task
      parameters:
        - $ref: '#/components/parameters/BoardId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/TaskPatch'
                - type: object
                  properties:
                    id: { $ref: '#/components/schemas/ClientId' }
                    workflowId: { type: string, format: uuid, nullable: true, description: Starts a new task at the active workflow's start node; omitted/null selects Default }
      responses:
        '200': { description: 'Existing task (idempotent replay of a client-supplied id; wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '201': { description: 'Task (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '400': { description: 'Rejected before any mutation, e.g. BLOCKER_NOT_ON_BOARD', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '422': { $ref: '#/components/responses/Error' }
  /api/boards/{boardId}/tasks/{taskId}:
    get:
      tags: [Tasks]
      summary: Get a task
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
      responses:
        '200': { description: 'Task (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
    patch:
      tags: [Tasks]
      summary: Patch a task
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TaskPatch' }
      responses:
        '200': { description: 'Task (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '400': { description: 'Rejected before any mutation, e.g. BLOCKER_NOT_ON_BOARD', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { description: 'Version conflict (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '422': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Tasks]
      summary: Delete a task
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/boards/{boardId}/tasks/{taskId}/move:
    post:
      tags: [Tasks]
      summary: Move/reorder a task
      description: >
        Only `toStageId`, `beforeTaskId` and the optional `if*` guards are
        accepted. Unknown fields (e.g. `done`) return 400 — use PATCH to set
        task properties.


        The guards are preconditions checked inside the same transaction that
        performs the move, which is what lets an agent transition a card it read
        over an earlier HTTP round trip without racing whoever claimed it since.
        Any guard that does not hold returns `409` with the current task, the
        same shape `pop`, `claim` and PATCH `ifVersion` already return. A request
        that supplies no guard behaves exactly as before.


        A guarded move bumps the task `version`; an unguarded one does not, so
        an ordinary drag stays version-stable for clients doing their own CAS.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [toStageId]
              additionalProperties: false
              properties:
                toStageId: { type: string }
                beforeTaskId: { type: string, nullable: true }
                ifVersion: { type: integer, description: "Move only if the task is still at this `v`. Claiming (`pop`/`claim`) and releasing (`unlock`) bump it; a plain move or unguarded PATCH does not." }
                ifStageId: { type: string, description: Move only if the task is still in this stage. }
                ifUnblocked: { type: boolean, description: "Move only if no blocker of this task is still open. Tests the blockers' `done` flags, never the edge count — a lifted gate keeps its edge as history." }
                ifLockHolder: { type: string, description: "Move only if the live claim is held by exactly this holder. The empty string asserts there is no live claim at all." }
      responses:
        '200': { description: 'Task (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '400': { description: Missing toStageId, unsupported fields, or a guard of the wrong type }
        '409': { description: A guard did not hold, content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
  /api/boards/{boardId}/tasks/{taskId}/claim:
    post:
      tags: [Tasks]
      summary: Claim one specific task cooperatively
      description: >-
        Claims a single named task the same cooperative way pop claims the top
        one — same holder, ttlSeconds, TTL bounds, and stale-lock adoption. This
        is the verb that lets a caller take a task other than the top one (or one
        pop never serves, such as a task in a Review stage) without hand-editing
        the `locked` tag. Returns 404 if the task does not exist and 409 with the
        current task if it is already claimed by a live holder, so two agents
        claiming the same task collide loudly instead of silently duplicating
        work. Release it with the task unlock endpoint. `capabilities` do not
        apply here — an explicit claim targets one task by id.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClaimRequest' }
      responses:
        '200': { description: 'Task (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '404': { description: Task not found }
        '409': { description: 'Task already claimed (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
  /api/boards/{boardId}/tasks/{taskId}/unlock:
    post:
      tags: [Tasks]
      summary: Remove the cooperative locked tag from a task
      description: >
        Removes only tags whose lowercase value is `locked`, preserving all
        other tags and task fields. The body is optional and every drain loop
        omits it.


        `ifLockHolder` makes this "release the claim that is still mine".
        Without it, unlock is unconditional: an agent that observed a stale
        claim, decided to clear it, and got interrupted will on retry delete
        whatever claim it finds — including a live one somebody else acquired in
        between. Supplying the guard turns that into a `409` carrying the
        current task.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                ifLockHolder: { type: string, description: "Release only if the live claim is held by exactly this holder. The empty string asserts there is no live claim." }
      responses:
        '200': { description: 'Task (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '400': { description: Unsupported field in body }
        '409': { description: The claim is not held by ifLockHolder, content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
  /api/boards/{boardId}/tasks/{taskId}/workflow:
    put:
      tags: [Workflows]
      summary: Attach or detach a task workflow
      description: Attaching resets the task to the active workflow's start node. Null selects synthetic Default.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workflowId, ifVersion]
              properties:
                workflowId: { type: string, format: uuid, nullable: true }
                ifVersion: { type: integer, minimum: 1 }
      responses:
        '200': { description: 'Updated task (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '409': { description: 'Task version conflict (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '503': { $ref: '#/components/responses/Error' }
  /api/boards/{boardId}/tasks/{taskId}/transitions:
    get:
      tags: [Workflows]
      summary: List safe transitions available at the task's current node
      description: Returns labels and destination node names, never mutation bodies.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
      responses:
        '200':
          description: Current workflow state and available transitions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AvailableTransitions' }
  /api/boards/{boardId}/tasks/{taskId}/transitions/{transitionName}:
    post:
      tags: [Workflows]
      summary: Atomically trigger a workflow transition
      description: |
        Compares task version, node, and custom-workflow definition version; then
        applies every ordered mutation or none. requestId retries replay the exact
        stored status/body, including after task deletion, when the caller retains
        source-board edit authority; later destination revocation does not block
        replay. Organization transitions create one combined audit row.
        Transition-generated webhooks are ordered left_stage, moved, entered_stage, updated,
        workflow_transitioned; delete emits deleted then workflow_transitioned.
        Ordinary task patch webhook order is unchanged.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
        - name: transitionName
          in: path
          required: true
          schema: { type: string, maxLength: 80 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TransitionRequest' }
      responses:
        '200':
          description: Transition result or exact delete tombstone replay
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/WorkflowTransitionSuccess'
                  - $ref: '#/components/schemas/WorkflowTransitionDelete'
        '400': { $ref: '#/components/responses/Error' }
        '409':
          description: task_version_conflict, workflow_version_conflict, or idempotency_key_reused
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowConflict' }
        '422':
          description: Persisted machine-readable workflow error and current task
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowTransitionError' }
        '503': { $ref: '#/components/responses/Error' }
  /api/boards/{boardId}/tasks/{taskId}/workflow-error/dismiss:
    post:
      tags: [Workflows]
      summary: Dismiss the current workflow error
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ifVersion, errorId]
              properties:
                ifVersion: { type: integer, minimum: 1 }
                errorId: { type: string, format: uuid }
      responses:
        '200': { description: 'Updated task (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '409': { description: 'Stale version or error id (wrapped under task)', content: { application/json: { schema: { $ref: '#/components/schemas/TaskEnvelope' } } } }
        '503': { $ref: '#/components/responses/Error' }
  /api/boards/{boardId}/tasks/{taskId}/notes:
    get:
      tags: [Tasks]
      summary: List a task's notes (preview — requires FLUX_TASK_NOTES=1)
      description: >
        Paginated, chronologically ordered note records. Off unless
        FLUX_TASK_NOTES=1; when off this returns 503 TASK_NOTES_DISABLED.
        Native notes are never included in public board projections.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
        - name: cursor
          in: query
          required: false
          description: Opaque cursor from a previous response's nextCursor.
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: q
          in: query
          required: false
          description: Case-insensitive substring search over live note bodies; permission checked before search.
          schema: { type: string, maxLength: 500 }
      responses:
        '200':
          description: A page of notes
          content:
            application/json:
              schema:
                type: object
                properties:
                  notes: { type: array, items: { $ref: '#/components/schemas/TaskNote' } }
                  nextCursor: { type: string, nullable: true }
                  noteCount: { type: integer }
                  matchCount: { type: integer, description: Present when q is non-empty. }
                  query: { type: string, description: Present when q is non-empty. }
                  latestNoteAt: { type: integer, format: int64, nullable: true }
                  hasNotes: { type: boolean }
        '400': { description: Malformed cursor (TASK_NOTES_BAD_CURSOR) }
        '503': { description: Notes list disabled (TASK_NOTES_DISABLED) }
    post:
      tags: [Tasks]
      summary: Append a note (preview — requires FLUX_TASK_NOTES=1)
      description: >
        Appends one immutable note. Replaying the same requestId with the same
        body returns the original note with no side effects; reusing it with
        different content is refused. id is optional — omitting it lets the
        server mint one, and the server's choice is not part of the request, so
        an omitted-id retry still replays. Every limit is checked before anything
        is written, so a refusal leaves existing notes untouched.
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [requestId, body]
              properties:
                id: { $ref: '#/components/schemas/ClientId' }
                requestId: { $ref: '#/components/schemas/ClientId' }
                body: { type: string, maxLength: 20000, description: Measured in Unicode code points; also bounded to 81,920 UTF-8 bytes }
      responses:
        '200': { description: Existing note (exact replay of a requestId), content: { application/json: { schema: { type: object, properties: { note: { $ref: '#/components/schemas/TaskNote' }, task: { $ref: '#/components/schemas/Task' }, idempotent: { type: boolean } } } } } }
        '201': { description: Note, content: { application/json: { schema: { type: object, properties: { note: { $ref: '#/components/schemas/TaskNote' }, task: { $ref: '#/components/schemas/Task' }, idempotent: { type: boolean } } } } } }
        '409': { description: requestId reused with different content (TASK_NOTE_REQUEST_REUSED) or note id already taken (TASK_NOTE_ID_CONFLICT) }
        '422': { description: 'Limit refusal: TASK_NOTE_TOO_LONG, TASK_NOTE_TOO_LARGE, TASK_NOTES_TOO_MANY, or TASK_NOTES_TOTAL_TOO_LARGE' }
        '503': { description: Notes list disabled (TASK_NOTES_DISABLED) }
  /api/boards/{boardId}/tasks/{taskId}/notes/{noteId}:
    parameters:
      - $ref: '#/components/parameters/BoardId'
      - $ref: '#/components/parameters/TaskId'
      - name: noteId
        in: path
        required: true
        schema: { type: string, format: uuid }
    patch:
      tags: [Tasks]
      summary: Edit a note with per-note CAS
      description: >
        Appends the prior body to immutable revision history before changing the
        live note. A stale edit is preserved as one linked offline_conflict note
        and returned with 409; it is never silently overwritten or discarded.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [requestId, ifVersion, body]
              properties:
                requestId: { $ref: '#/components/schemas/ClientId' }
                recoveryId: { $ref: '#/components/schemas/ClientId' }
                ifVersion: { type: integer, minimum: 1, description: Note version, not task version }
                body: { type: string, maxLength: 20000 }
      responses:
        '200': { description: Updated note, or frozen response on exact replay }
        '409': { description: Stale edit preserved as recoveredNote, or changed requestId reuse }
        '422': { description: Note or aggregate limit refusal; mutation-free }
        '503': { description: Notes list disabled (TASK_NOTES_DISABLED) }
    delete:
      tags: [Tasks]
      summary: Tombstone a note with per-note CAS
      description: Never physically deletes accepted note text or revision history.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [requestId, ifVersion]
              properties:
                requestId: { $ref: '#/components/schemas/ClientId' }
                ifVersion: { type: integer, minimum: 1, description: Note version, not task version }
      responses:
        '200': { description: Tombstoned note, or frozen response on exact replay }
        '409': { description: Note changed and was not deleted, or changed requestId reuse }
        '503': { description: Notes list disabled (TASK_NOTES_DISABLED) }
  /api/boards/{boardId}/tasks/{taskId}/checklist:
    get:
      tags: [Checklist]
      summary: List checklist items
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
      responses:
        '200': { description: Checklist, content: { application/json: { schema: { type: object, properties: { checklist: { type: array, items: { $ref: '#/components/schemas/ChecklistItem' } } } } } } }
    post:
      tags: [Checklist]
      summary: Add checklist item
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                id: { $ref: '#/components/schemas/ClientId' }
                text: { type: string }
                done: { type: boolean }
      responses:
        '200': { description: Existing item (idempotent replay of a client-supplied id), content: { application/json: { schema: { type: object, properties: { item: { $ref: '#/components/schemas/ChecklistItem' } } } } } }
        '201': { description: Item, content: { application/json: { schema: { type: object, properties: { item: { $ref: '#/components/schemas/ChecklistItem' } } } } } }
  /api/boards/{boardId}/tasks/{taskId}/checklist/{checklistId}:
    patch:
      tags: [Checklist]
      summary: Patch checklist item
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
        - name: checklistId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                text: { type: string }
                done: { type: boolean }
      responses:
        '200': { description: Item, content: { application/json: { schema: { type: object, properties: { item: { $ref: '#/components/schemas/ChecklistItem' } } } } } }
    delete:
      tags: [Checklist]
      summary: Delete checklist item
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
        - name: checklistId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/boards/{boardId}/tasks/{taskId}/attachments:
    post:
      tags: [Files]
      summary: Upload task attachment compatibility endpoint
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
        - $ref: '#/components/parameters/XFilename'
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        '201': { description: Attachment, content: { application/json: { schema: { type: object, properties: { attachment: { $ref: '#/components/schemas/File' }, rev: { type: integer } } } } } }
  /api/boards/{boardId}/tasks/{taskId}/attachments/{fileId}:
    get:
      tags: [Files]
      summary: Download or view task attachment bytes
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
        - $ref: '#/components/parameters/FileId'
      responses:
        '200': { description: File bytes }
    delete:
      tags: [Files]
      summary: Delete/unlink task attachment
      parameters:
        - $ref: '#/components/parameters/BoardId'
        - $ref: '#/components/parameters/TaskId'
        - $ref: '#/components/parameters/FileId'
      responses:
        '200': { description: Result, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, rev: { type: integer } } } } } }
  /api/files:
    get:
      tags: [Files]
      summary: List visible files
      responses:
        '200': { description: Files, content: { application/json: { schema: { type: object, properties: { files: { type: array, items: { $ref: '#/components/schemas/File' } } } } } } }
    post:
      tags: [Files]
      summary: Upload a personal, board, or task-linked file
      parameters:
        - name: boardId
          in: query
          schema: { type: string }
        - name: taskId
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/XFilename'
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        '201': { description: File, content: { application/json: { schema: { type: object, properties: { file: { $ref: '#/components/schemas/File' } } } } } }
  /api/files/{fileId}:
    get:
      tags: [Files]
      summary: Get file bytes
      parameters:
        - $ref: '#/components/parameters/FileId'
        - name: download
          in: query
          schema: { type: string }
      responses:
        '200': { description: File bytes }
    put:
      tags: [Files]
      summary: Replace file bytes and optional filename
      parameters:
        - $ref: '#/components/parameters/FileId'
        - $ref: '#/components/parameters/XFilename'
      requestBody:
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
          text/plain:
            schema: { type: string }
      responses:
        '200': { description: File, content: { application/json: { schema: { type: object, properties: { file: { $ref: '#/components/schemas/File' } } } } } }
    patch:
      tags: [Files]
      summary: Update file metadata
      parameters:
        - $ref: '#/components/parameters/FileId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                description: { type: string }
      responses:
        '200': { description: File, content: { application/json: { schema: { type: object, properties: { file: { $ref: '#/components/schemas/File' } } } } } }
    delete:
      tags: [Files]
      summary: Delete a file
      parameters:
        - $ref: '#/components/parameters/FileId'
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/files/{fileId}/links:
    post:
      tags: [Files]
      summary: Link file to a board or task
      parameters:
        - $ref: '#/components/parameters/FileId'
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FileLinkRequest' }
      responses:
        '200': { description: File, content: { application/json: { schema: { type: object, properties: { file: { $ref: '#/components/schemas/File' } } } } } }
    delete:
      tags: [Files]
      summary: Remove file link from a board or task
      parameters:
        - $ref: '#/components/parameters/FileId'
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FileLinkRequest' }
      responses:
        '200': { description: Result, content: { application/json: { schema: { type: object, properties: { file: { anyOf: [{ $ref: '#/components/schemas/File' }, { type: 'null' }] }, deleted: { type: boolean } } } } } }
  /api/calendar:
    get:
      tags: [Calendar]
      summary: Aggregate date-bearing tasks and project dates
      parameters:
        - name: from
          in: query
          schema: { type: integer, format: int64 }
        - name: to
          in: query
          schema: { type: integer, format: int64 }
      responses:
        '200': { description: Calendar events, content: { application/json: { schema: { type: object, properties: { events: { type: array, items: { $ref: '#/components/schemas/CalendarEvent' } } } } } } }
  /api/preview:
    get:
      tags: [Preview]
      summary: Fetch Open Graph preview metadata for a URL
      parameters:
        - name: url
          in: query
          required: true
          schema: { type: string, format: uri }
      responses:
        '200': { description: Preview, content: { application/json: { schema: { $ref: '#/components/schemas/Preview' } } } }
  /api/presence:
    post:
      tags: [Boards]
      summary: Report current active board for presence
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                boardId: { type: string, nullable: true }
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/public/boards/{boardId}:
    get:
      tags: [Public]
      summary: Get a public board anonymously
      security: []
      parameters:
        - $ref: '#/components/parameters/BoardId'
      responses:
        '200': { description: Public board, content: { application/json: { schema: { $ref: '#/components/schemas/BoardResponse' } } } }
  /api/public/boards/{boardId}/presence:
    post:
      tags: [Public]
      summary: Report anonymous public viewer presence
      security: []
      parameters:
        - $ref: '#/components/parameters/BoardId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                viewerId: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/admin/users:
    get:
      tags: [Admin]
      summary: List users
      responses:
        '200': { description: Users, content: { application/json: { schema: { type: object, properties: { users: { type: array, items: { $ref: '#/components/schemas/AdminUser' } } } } } } }
    post:
      tags: [Admin]
      summary: Create user
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                name: { type: string }
                password: { type: string, minLength: 8 }
                level: { type: string }
                admin: { type: boolean }
      responses:
        '201': { description: User, content: { application/json: { schema: { type: object, properties: { user: { $ref: '#/components/schemas/AdminUser' } } } } } }
  /api/admin/users/{userId}:
    patch:
      tags: [Admin]
      summary: Patch user profile, entitlements, admin, or org settings
      parameters:
        - $ref: '#/components/parameters/UserId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email }
                name: { type: string }
                level: { type: string }
                admin: { type: boolean }
                emailVerified: { type: boolean }
                org: { $ref: '#/components/schemas/OrgPatch' }
      responses:
        '200': { description: User, content: { application/json: { schema: { type: object, properties: { user: { $ref: '#/components/schemas/AdminUser' } } } } } }
    delete:
      tags: [Admin]
      summary: Delete user
      parameters:
        - $ref: '#/components/parameters/UserId'
      responses:
        '200': { $ref: '#/components/responses/Ok' }
  /api/admin/levels:
    get:
      tags: [Admin]
      summary: List account levels
      responses:
        '200': { description: Levels, content: { application/json: { schema: { type: object, properties: { levels: { type: array, items: { $ref: '#/components/schemas/Level' } } } } } } }
    post:
      tags: [Admin]
      summary: Create account level
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Level' }
      responses:
        '201': { description: Level, content: { application/json: { schema: { type: object, properties: { level: { $ref: '#/components/schemas/Level' } } } } } }
  /api/admin/levels/{levelId}:
    patch:
      tags: [Admin]
      summary: Patch account level
      parameters:
        - name: levelId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Level' }
      responses:
        '200': { description: Level, content: { application/json: { schema: { type: object, properties: { level: { $ref: '#/components/schemas/Level' } } } } } }
    delete:
      tags: [Admin]
      summary: Delete account level
      parameters:
        - name: levelId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Ok' }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: flux token
    cookieAuth:
      type: apiKey
      in: cookie
      name: flux_session
  parameters:
    BoardId:
      name: boardId
      in: path
      required: true
      schema: { type: string }
    StageId:
      name: stageId
      in: path
      required: true
      schema: { type: string }
    RoutingKey:
      name: routingKey
      in: path
      required: true
      description: Exact recipient address or recipient domain, normalized the way the delivery path derives it (lowercase, IDNA-ASCII, "+tag" stripped)
      schema: { type: string }
    TaskId:
      name: taskId
      in: path
      required: true
      schema: { type: string }
    FileId:
      name: fileId
      in: path
      required: true
      schema: { type: string }
    UserId:
      name: userId
      in: path
      required: true
      schema: { type: string }
    OrgId:
      name: orgId
      in: path
      required: true
      schema: { type: string }
    XFilename:
      name: X-Filename
      in: header
      required: false
      schema: { type: string }
      description: URL-encoded filename for raw uploads.
  responses:
    Ok:
      description: OK
      content:
        application/json:
          schema:
            type: object
            properties:
              ok: { type: boolean }
    Error:
      description: Error response
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    BoardWriteConflict:
      description: >-
        A stale whole-board write. Either the `If-Match` is behind (no `code`),
        or the document would erase live mail: it omits a stage an inbound route
        points at (`ROUTED_STAGE_MISSING`, with the offending `routes`), or it
        keeps that stage but omits a message delivered into it
        (`ROUTED_TASK_MISSING`, with the offending `tasks`). All three carry the
        current `board` and `rev`, so one refetch-merge-retry branch handles
        them all; `code` says which merge is needed. Shared by
        `PUT /api/boards/{boardId}` and `POST /api/boards/{boardId}/restore`.
      content:
        application/json:
          schema:
            type: object
            properties:
              board: { $ref: '#/components/schemas/Board' }
              rev: { type: integer }
              error: { type: string }
              code: { type: string, enum: [ROUTED_STAGE_MISSING, ROUTED_TASK_MISSING] }
              routes:
                type: array
                items:
                  type: object
                  properties:
                    routingKey: { type: string }
                    stageId: { type: string }
              tasks:
                type: array
                items:
                  type: object
                  properties:
                    routingKey: { type: string }
                    stageId: { type: string }
                    taskId: { type: string }
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
        code: { type: string, description: Stable machine-readable error code when available }
        field: { type: string }
        limit: { type: integer }
        actual: { type: integer }
        measurement: { type: string, enum: [unicode_code_points] }
        boardId: { type: string, description: BLOCKER_NOT_ON_BOARD — the board the write targeted }
        taskId: { type: string, description: BLOCKER_NOT_ON_BOARD — the task whose blockedBy was rejected }
        blockerId: { type: string, description: BLOCKER_NOT_ON_BOARD — the offending id }
        location:
          type: string
          description: >
            BLOCKER_NOT_ON_BOARD, document-shaped writes only — the path to the
            rejected entry in the request body, e.g. tasks[3].blockedBy[0].
    UserResponse:
      type: object
      properties:
        user: { $ref: '#/components/schemas/User' }
        verification: { $ref: '#/components/schemas/VerificationStatus' }
    VerificationStatus:
      type: object
      description: >-
        Where the account sits in the email-verification journey. Returned by
        register, login, GET /api/me, verify and verify/resend. Read
        `required: false` as the terminal signal rather than matching the status
        string, so an unrecognised future status still ends the journey. Only
        `sent` means a message reached the provider — cooldown, already_verified
        and not_required are all HTTP 200 with nothing sent.
      properties:
        required: { type: boolean, description: false once the address is confirmed; terminal }
        status:
          type: string
          enum: [verified, not_required, already_verified, sent, cooldown, expired, needs_resend, send_failed, unavailable]
        code: { type: string, nullable: true, enum: [EMAIL_VERIFICATION_REQUIRED] }
        email: { type: string, description: the address the link was sent to, so a typo is noticeable }
        expiresAt: { type: integer, nullable: true, description: epoch ms the current link stops working }
        retryAfterMs: { type: integer, description: how long until another send is allowed }
        canResend: { type: boolean }
        canChangeEmail: { type: boolean, description: always false today — only an admin can move an address }
        error: { type: string, nullable: true, description: why delivery failed }
    User:
      type: object
      properties:
        id: { type: string }
        email: { type: string, format: email }
        name: { type: string }
        avatarUrl: { type: string, nullable: true }
        prefs: { $ref: '#/components/schemas/Prefs' }
        level: { $ref: '#/components/schemas/Level' }
        admin: { type: boolean }
        emailVerified: { type: boolean }
        usage: { $ref: '#/components/schemas/Usage' }
        org: { $ref: '#/components/schemas/Org' }
        orgMemberships:
          type: array
          items: { $ref: '#/components/schemas/OrgMembership' }
    Prefs:
      type: object
      properties:
        theme: { type: string, enum: [light, dark] }
        activeBoardId: { type: string, nullable: true }
        boardOrder: { type: array, items: { type: string } }
        sidebar: { type: object, additionalProperties: true }
        sidebarVersion: { type: integer, minimum: 0 }
        ifSidebarVersion: { type: integer, minimum: 0, description: Required when replacing sidebar wholesale }
        sidebarOps:
          type: array
          maxItems: 100
          items:
            type: object
            required: [type]
            properties:
              type:
                type: string
                enum: [setGlobalsCompact, setGroupCollapsed, addFolder, removeFolder, setFolderCollapsed, moveFolder, moveProject]
              groupKey: { type: string }
              folderId: { type: string }
              folder: { type: object, additionalProperties: true }
              projectId: { type: string }
              beforeFolderId: { type: string, nullable: true }
              beforeProjectId: { type: string, nullable: true }
              collapsed: { type: boolean }
              compact: { type: boolean }
    Usage:
      type: object
      properties:
        storageBytes: { type: integer }
        boardsOwned: { type: integer }
    Level:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        maxStorageBytes: { type: integer }
        maxBoards: { type: integer }
        canPublicBoards: { type: boolean }
    Org:
      type: object
      properties:
        enabled: { type: boolean }
        maxMembers: { type: integer }
        members:
          type: array
          items: { $ref: '#/components/schemas/OrgMember' }
    OrgPatch:
      type: object
      properties:
        enabled: { type: boolean }
        maxMembers: { type: integer }
        members:
          type: array
          items:
            anyOf:
              - type: string
              - $ref: '#/components/schemas/OrgMember'
    OrgMember:
      type: object
      properties:
        email: { type: string, format: email }
        userId: { type: string, nullable: true }
        addedAt: { type: integer, format: int64 }
    OrgMembership:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        email: { type: string }
        avatarUrl: { type: string, nullable: true }
        self: { type: boolean }
    ApiToken:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        createdAt: { type: integer, format: int64 }
        lastUsed: { type: integer, format: int64, nullable: true }
    Template:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        scope: { type: string }
        ownerId: { type: string, nullable: true }
        orgOwnerId: { type: string, nullable: true }
        stages:
          type: array
          items:
            oneOf:
              - { type: string }
              - { type: object, properties: { name: { type: string }, defaultTaskTemplateId: { type: string, nullable: true } } }
        customFields: { type: array, items: { $ref: '#/components/schemas/CustomField' } }
        taskTemplates: { type: array, items: { $ref: '#/components/schemas/TaskTemplate' } }
    BoardSummary:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        emoji: { type: string }
        color: { type: string }
        view: { $ref: '#/components/schemas/BoardView' }
        date: { type: integer, format: int64, nullable: true }
        description: { type: string }
        url: { type: string }
        background: { $ref: '#/components/schemas/BoardBackground' }
        role: { type: string, enum: [viewer, editor, owner] }
        ownerId: { type: string }
        ownerName: { type: string }
        ownerEmail: { type: string }
        orgOwned: { type: boolean }
        orgName: { type: string, nullable: true }
        inbound: { $ref: '#/components/schemas/InboundStatus' }
        archived: { type: boolean }
        public: { type: boolean }
        rev: { type: integer }
        stages: { type: integer }
        tasks: { type: integer }
        done: { type: integer }
        members: { type: array, items: { $ref: '#/components/schemas/Member' } }
        viewers: { type: array, items: { $ref: '#/components/schemas/Member' } }
    BoardResponse:
      type: object
      properties:
        board: { $ref: '#/components/schemas/Board' }
        rev: { type: integer }
        role: { type: string, enum: [viewer, editor, owner] }
    ClientId:
      type: string
      format: uuid
      description: >
        Optional client-supplied UUID (8-4-4-4-12 hex, case-insensitive; stored
        lowercased). Enables retry-safe creates: replaying a create with an id
        that already exists in the expected scope returns 200 with the existing
        entity and no side effects. A malformed id, or an id that exists outside
        the expected scope, returns 400 {"error":"id unavailable"}.
    CreateBoard:
      type: object
      properties:
        id: { $ref: '#/components/schemas/ClientId' }
        name: { type: string }
        emoji: { type: string }
        color: { type: string }
        view: { $ref: '#/components/schemas/BoardView' }
        date: { type: integer, format: int64, nullable: true }
        description: { type: string }
        url: { type: string }
        background: { $ref: '#/components/schemas/BoardBackground' }
        stages:
          type: array
          description: Each entry is a plain name string or a {id?, name} object.
          items:
            oneOf:
              - type: string
              - type: object
                required: [name]
                properties:
                  id: { $ref: '#/components/schemas/ClientId' }
                  name: { type: string }
        templateId: { type: string }
    BoardPatch:
      type: object
      properties:
        name: { type: string }
        emoji: { type: string }
        color: { type: string }
        view: { $ref: '#/components/schemas/BoardView' }
        date: { type: integer, format: int64, nullable: true }
        description: { type: string }
        url: { type: string }
        background: { $ref: '#/components/schemas/BoardBackground' }
        customFields: { type: array, items: { $ref: '#/components/schemas/CustomField' } }
        archived: { type: boolean }
        public: { type: boolean }
    BoardBackground:
      type: object
      properties:
        light: { type: string }
        dark: { type: string }
        blur: { type: number, minimum: 0, maximum: 40 }
    BoardPut:
      allOf:
        - $ref: '#/components/schemas/BoardPatch'
        - type: object
          required: [stages, tasks]
          properties:
            stages: { type: array, items: { $ref: '#/components/schemas/Stage' } }
            tasks: { type: array, items: { $ref: '#/components/schemas/Task' } }
    Board:
      allOf:
        - $ref: '#/components/schemas/BoardPatch'
        - type: object
          properties:
            id: { type: string }
            ownerId: { type: string }
            role: { type: string }
            orgOwned: { type: boolean }
            rev: { type: integer }
            createdAt: { type: integer, format: int64 }
            members: { type: object, additionalProperties: { type: string } }
            taskTemplates: { type: array, items: { $ref: '#/components/schemas/TaskTemplate' } }
            stages: { type: array, items: { $ref: '#/components/schemas/Stage' } }
            tasks: { type: array, items: { $ref: '#/components/schemas/Task' } }
            files: { type: array, items: { $ref: '#/components/schemas/File' } }
            inbound: { $ref: '#/components/schemas/InboundStatus' }
    BoardBackup:
      type: object
      properties:
        version: { type: integer }
        board:
          type: object
          properties:
            name: { type: string }
            emoji: { type: string }
            color: { type: string }
            view: { $ref: '#/components/schemas/BoardView' }
            date: { type: integer, format: int64, nullable: true }
            description: { type: string }
            url: { type: string }
            background: { $ref: '#/components/schemas/BoardBackground' }
            customFields: { type: array, items: { $ref: '#/components/schemas/CustomField' } }
            taskTemplates: { type: array, items: { $ref: '#/components/schemas/TaskTemplate' } }
            stages: { type: array, items: { $ref: '#/components/schemas/Stage' } }
            tasks: { type: array, items: { $ref: '#/components/schemas/Task' } }
    BoardView:
      type: string
      enum: [board, list, timeline, calendar, files]
    Stage:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        color: { type: string }
        description: { type: string }
        url: { type: string }
        headerImage: { type: string }
        headerCrop: { type: string, description: "Image crop position such as '50% 50%'" }
        terminal: { type: boolean, description: "Stage holds completed work: tasks moved in are marked done (releasing anything they block); tasks moved out are reopened" }
        defaultTaskTemplateId: { type: string, nullable: true }
        order: { type: number }
    CustomField:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        type: { type: string, enum: [text, number, date, select, multi-select, checkbox, url] }
        options: { type: array, items: { type: string } }
    Member:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        email: { type: string }
        avatarUrl: { type: string, nullable: true }
        role: { type: string, enum: [viewer, editor, owner] }
        viaOrg: { type: boolean }
    Webhook:
      allOf:
        - $ref: '#/components/schemas/WebhookPatch'
        - type: object
          properties:
            id: { type: string }
            boardId: { type: string }
            createdAt: { type: integer, format: int64 }
            updatedAt: { type: integer, format: int64 }
            secret: { type: string, description: Returned only at creation time }
    WebhookPatch:
      type: object
      properties:
        stageId: { type: string, nullable: true }
        url: { type: string }
        events: { type: array, items: { type: string, enum: [task.created, task.updated, task.deleted, task.moved, task.entered_stage, task.left_stage, task.workflow_transitioned, task.workflow_error, task.workflow_error_dismissed, task.note_created, task.note_updated, task.note_deleted] } }
        active: { type: boolean }
    WebhookDelivery:
      type: object
      properties:
        id: { type: string }
        webhookId: { type: string }
        boardId: { type: string }
        event: { type: string }
        attempt: { type: integer }
        status: { type: string, enum: [pending, success, failed] }
        responseStatus: { type: integer, nullable: true }
        error: { type: string }
        createdAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64 }
    WorkflowSummary:
      type: object
      required: [id, accountId, name, version, start, deleted]
      properties:
        id: { type: string, format: uuid }
        accountId: { type: string }
        name: { type: string, maxLength: 120 }
        description: { type: string, maxLength: 20000 }
        version: { type: integer, minimum: 1 }
        start: { type: string, maxLength: 80 }
        deleted: { type: boolean }
        createdAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64 }
    Workflow:
      allOf:
        - $ref: '#/components/schemas/WorkflowSummary'
        - type: object
          properties:
            nodes:
              type: array
              minItems: 1
              maxItems: 200
              items: { $ref: '#/components/schemas/WorkflowNode' }
            transitions:
              type: array
              maxItems: 500
              items: { $ref: '#/components/schemas/WorkflowTransition' }
    WorkflowDefinition:
      type: object
      required: [name]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, minLength: 1, maxLength: 120 }
        description: { type: string, maxLength: 20000 }
        start: { type: string, maxLength: 80 }
        nodes:
          type: array
          minItems: 1
          maxItems: 200
          items: { $ref: '#/components/schemas/WorkflowNode' }
        transitions:
          type: array
          maxItems: 500
          items: { $ref: '#/components/schemas/WorkflowTransition' }
    WorkflowNode:
      type: object
      required: [name, label]
      properties:
        name: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$', maxLength: 80 }
        label: { type: string, minLength: 1, maxLength: 120 }
        ui:
          type: object
          nullable: true
          properties:
            x: { type: number }
            y: { type: number }
    WorkflowTransition:
      type: object
      required: [name, label, from, to]
      properties:
        name: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$', maxLength: 80 }
        label: { type: string, minLength: 1, maxLength: 120 }
        from: { type: string, maxLength: 80 }
        to: { type: string, maxLength: 80 }
        order: { type: number }
        mutations:
          type: array
          maxItems: 50
          items: { $ref: '#/components/schemas/WorkflowMutation' }
    WorkflowMutation:
      oneOf:
        - type: object
          required: [type, boardId, stageId]
          properties:
            type: { const: move }
            boardId: { type: string, format: uuid }
            stageId: { type: string, format: uuid }
        - type: object
          required: [type, field, value]
          properties:
            type: { const: set }
            field: { type: string, enum: [title, notes, priority, size, color, tags, start, due, recurrence, url, repo, done, assigneeId, blockedBy, previewImage] }
            value: {}
        - type: object
          required: [type, field, value]
          properties:
            type: { const: setCustomField }
            field:
              type: object
              required: [name, type]
              properties:
                name: { type: string }
                type: { type: string, enum: [text, number, date, select, multi-select, checkbox, url] }
            value: {}
        - type: object
          required: [type]
          properties:
            type: { const: deleteTask }
    WorkflowError:
      type: object
      required: [id, code, message, node, at]
      properties:
        id: { type: string, format: uuid }
        code: { type: string, enum: [workflow_deleted, missing_node, missing_transition, missing_board, missing_stage, invalid_fixed_value, missing_custom_field, ambiguous_custom_field, incompatible_custom_value, invalid_assignee, cross_board_dependency, mutation_failed] }
        message: { type: string }
        workflowId: { type: string, format: uuid, nullable: true }
        workflowVersion: { type: integer, nullable: true }
        node: { type: string }
        transition: { type: string, nullable: true }
        mutationIndex: { type: integer, nullable: true }
        at: { type: integer, format: int64 }
    AvailableTransitions:
      type: object
      properties:
        taskId: { type: string }
        v: { type: integer }
        workflow:
          type: object
          properties:
            id: { type: string, format: uuid, nullable: true }
            name: { type: string }
            version: { type: integer, nullable: true }
        node:
          type: object
          properties:
            name: { type: string }
            label: { type: string }
        error:
          anyOf:
            - type: 'null'
            - $ref: '#/components/schemas/WorkflowError'
        transitions:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              label: { type: string }
              to: { type: string }
              order: { type: number }
    TransitionRequest:
      type: object
      required: [ifVersion, ifNode, requestId]
      properties:
        ifVersion: { type: integer, minimum: 1 }
        ifNode: { type: string }
        ifWorkflowVersion: { type: integer, nullable: true, description: Required for custom workflows; absent/null for Default }
        requestId: { type: string, format: uuid }
    WorkflowTransitionSuccess:
      type: object
      required: [deleted, task, runId, requestId]
      properties:
        deleted: { type: boolean, enum: [false] }
        task: { $ref: '#/components/schemas/Task' }
        runId: { type: string, format: uuid }
        requestId: { type: string, format: uuid }
    WorkflowTransitionDelete:
      type: object
      required: [deleted, taskId, runId, requestId, transition, deletedAt]
      properties:
        deleted: { type: boolean, enum: [true] }
        taskId: { type: string }
        runId: { type: string, format: uuid }
        requestId: { type: string, format: uuid }
        deletedAt: { type: integer, format: int64 }
        transition:
          type: object
          required: [workflowId, name, from, to]
          properties:
            workflowId: { type: string, format: uuid, nullable: true }
            name: { type: string }
            from: { type: string }
            to: { type: string }
    WorkflowTransitionError:
      type: object
      required: [code, error, task, runId, requestId]
      properties:
        code: { type: string }
        error: { $ref: '#/components/schemas/WorkflowError' }
        task: { $ref: '#/components/schemas/Task' }
        runId: { type: string, format: uuid }
        requestId: { type: string, format: uuid }
    WorkflowConflict:
      type: object
      required: [code]
      properties:
        code: { type: string, enum: [task_version_conflict, workflow_version_conflict, idempotency_key_reused] }
        task: { $ref: '#/components/schemas/Task' }
        workflow: { $ref: '#/components/schemas/Workflow' }
        requestedBoardId: { type: string }
    InboundStatus:
      type: object
      required: [enabled]
      properties:
        enabled: { type: boolean }
    InboundConfig:
      type: object
      required: [configured, active, endpointUrl, hasApiKey, hasSigningSecret, receiveMatch, filterMode, filterList, maxAttachmentBytes, allowedAttachmentTypes, retentionDays, jobs]
      properties:
        configured: { type: boolean }
        active: { type: boolean }
        endpointUrl: { type: string, format: uri, nullable: true }
        hasApiKey: { type: boolean }
        hasSigningSecret: { type: boolean }
        receiveMatch: { type: array, items: { type: string } }
        filterMode: { type: string, enum: [none, whitelist, blacklist] }
        filterList: { type: array, items: { type: string } }
        maxAttachmentBytes: { type: integer, minimum: 0, maximum: 15728640 }
        allowedAttachmentTypes: { type: array, items: { type: string } }
        retentionDays: { type: integer, minimum: 1, maximum: 365 }
        lastError: { type: string, nullable: true }
        jobs: { type: object, additionalProperties: { type: integer } }
    InboundPatch:
      type: object
      properties:
        resendApiKey: { type: string, writeOnly: true }
        signingSecret: { type: string, writeOnly: true }
        receiveMatch: { type: array, maxItems: 100, items: { type: string } }
        filterMode: { type: string, enum: [none, whitelist, blacklist] }
        filterList: { type: array, maxItems: 100, items: { type: string } }
        maxAttachmentBytes: { type: integer, minimum: 0, maximum: 15728640 }
        allowedAttachmentTypes: { type: array, maxItems: 30, items: { type: string } }
        retentionDays: { type: integer, minimum: 1, maximum: 365 }
        active: { type: boolean }
    InboundJob:
      type: object
      properties:
        id: { type: string }
        emailId: { type: string }
        status: { type: string, enum: [pending, processing, done, dropped, dead] }
        attempts: { type: integer }
        nextAttemptAt: { type: integer, format: int64 }
        error: { type: string, nullable: true, description: Redacted diagnostic }
        createdAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64 }
    InboundRouteStageRef:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        taskCount: { type: integer }
    InboundRoute:
      type: object
      properties:
        routingKey: { type: string, description: Exact recipient address or recipient domain this route matches }
        kind: { type: string, enum: [address, domain], description: address routes match one exact recipient and are consulted first; domain routes are the fallback for every unmapped local part }
        stageId: { type: string }
        stageName: { type: string, nullable: true }
        stageTaskCount: { type: integer }
        mailboxFull: { type: boolean, description: stageTaskCount has reached stageTaskCap, so the next delivery to this route is refused }
        matchesRoutingKey: { type: boolean, description: The routed stage is the one named for the routing key }
        generatedShadow: { type: boolean, description: The routed stage's name is exactly the shadow name Flux generates on a name collision; always false for an address route }
        generatedShadowName: { type: string, nullable: true, description: '<domain(0,71)>-<sha256(domain)[0:8]>; null for an address route, which Flux never generated a stage for' }
        webhookCount: { type: integer, description: Webhooks pointing at the routed stage; deleting it would cascade them away }
        stageNameCollision: { type: boolean, description: Another stage on this board shares the routed stage's lower(name) }
        namedStage: { allOf: [{ $ref: '#/components/schemas/InboundRouteStageRef' }], nullable: true, description: A different stage named for the routing key, if one exists; always null for an address route }
        movableTaskCount: { type: integer, description: Tasks a repoint with moveTasks would carry — everything that did not provably arrive through another route }
        movableFitsNamedStage: { type: boolean, nullable: true, description: Whether merging movableTaskCount onto namedStage stays strictly under the cap }
    InboundRoutesReport:
      type: object
      required: [routes, unroutedDomainStages, stageNameCollisions, stageTaskCap]
      properties:
        routes: { type: array, items: { $ref: '#/components/schemas/InboundRoute' } }
        unroutedDomainStages:
          type: array
          description: Domain-shaped stages not targeted by a route for the same normalized domain, in stable stage order. Address-shaped and ordinary lane names are excluded, while duplicate same-domain stages remain separate. A non-empty entry is drift requiring an explicit PUT /inbound/routes/{routingKey} decision, never permission to infer a route or send test mail.
          items: { $ref: '#/components/schemas/InboundRouteStageRef' }
        stageNameCollisions:
          type: array
          description: Every group of stages on the board sharing a lower(name) — the condition that produces a shadow stage
          items:
            type: object
            properties:
              name: { type: string }
              stages: { type: array, items: { $ref: '#/components/schemas/InboundRouteStageRef' } }
        stageTaskCap: { type: integer, description: Per-stage delivery cap; deliveries refuse at this count }
    InboundRouteChange:
      type: object
      required: [created, route, movedTaskCount]
      properties:
        created: { type: boolean, description: The routing key did not exist before this call }
        route: { $ref: '#/components/schemas/InboundRoute' }
        movedTaskCount: { type: integer }
        previousStage:
          nullable: true
          type: object
          properties:
            id: { type: string }
            name: { type: string }
            taskCount: { type: integer }
            deleted: { type: boolean }
            keptBecause:
              type: string
              nullable: true
              enum: [not_a_generated_shadow, has_tasks, routed, webhooks]
              description: Why dropSourceStage did not delete the stage the route left
    PopRequest:
      type: object
      description: >-
        Optional claim identity, lease length, and caller capabilities. All may
        be omitted (a bare `{}` is the common case). Unknown fields are rejected
        with 400 rather than ignored, so a routing-relevant typo like
        `capabilties` cannot silently disable capability filtering.
      additionalProperties: false
      properties:
        holder: { type: string, maxLength: 200, description: Free-form id of whoever is claiming the task. }
        ttlSeconds: { type: integer, minimum: 1, maximum: 86400, default: 3600, description: How long the claim lasts before another pop may take it. }
        capabilities:
          type: array
          items: { type: string, maxLength: 100 }
          description: >-
            What the caller can do. Pop skips any task carrying a `needs:<cap>`
            tag not covered here (all of a task's `needs:*` tags must be
            satisfied; matching is case-insensitive). A task with no `needs:*`
            tag is unaffected, so omitting this behaves exactly as before for
            ordinary work.
    BoardPopRequest:
      type: object
      description: >-
        The board-wide pop's body: everything PopRequest accepts, plus the
        optional allowed-stage list. Kept separate from PopRequest because the
        stage-scoped pop routes and `claim` must keep rejecting `stageIds` as an
        unsupported field — their stage is already in the path, so accepting and
        ignoring an allowed-stage list there is precisely the silent no-op that
        pop's fail-closed field checking exists to prevent.


        Spelled out in full rather than composed with `allOf` from PopRequest:
        `additionalProperties: false` is not inherited through `allOf`, it is
        applied per subschema, so the composed form would reject `holder` in the
        stageIds branch and `stageIds` in the PopRequest branch and validate no
        body at all. Both schemas are closed on purpose — that is the whole
        point of pop's fail-closed field checking — so they cannot be composed.
      additionalProperties: false
      properties:
        holder: { type: string, maxLength: 200, description: Free-form id of whoever is claiming the task. }
        ttlSeconds: { type: integer, minimum: 1, maximum: 86400, default: 3600, description: How long the claim lasts before another pop may take it. }
        capabilities:
          type: array
          items: { type: string, maxLength: 100 }
          description: >-
            What the caller can do. Pop skips any task carrying a `needs:<cap>`
            tag not covered here. Identical to PopRequest.capabilities.
        stageIds:
          type: array
          minItems: 1
          maxItems: 100
          items: { type: string, minLength: 1, maxLength: 100 }
          description: >-
            Stage ids on this board to consider, instead of every stage.
            Selection stays board-wide within the set and is ranked by priority
            first, canonical board stage order second, task order third. Omit it
            for the historical whole-board behaviour; every existing caller is
            unaffected. An empty array, an explicit null, a non-string entry or
            more than 100 entries is a 400, and an id belonging to another board
            is a 404 STAGE_NOT_FOUND — never a silently narrower queue.
            Duplicates are ignored.


            Advertised as `pop.stageIds` in the `apiCapabilities` list on
            `GET /api/config`, so a client that may be talking to an older
            deployment can negotiate the field instead of discovering the 400
            by sending a real pop.
    ClaimRequest:
      type: object
      description: Optional claim identity and lease length for claiming one specific task. Both may be omitted.
      properties:
        holder: { type: string, maxLength: 200, description: Free-form id of whoever is claiming the task. }
        ttlSeconds: { type: integer, minimum: 1, maximum: 86400, default: 3600, description: How long the claim lasts before another claim may take it. }
    TaskLock:
      type: object
      nullable: true
      description: >-
        The current claim on a task, or null when it is free. Exposed so a stale
        claim is visible rather than inferred from an absence.
      properties:
        holder: { type: string, nullable: true }
        acquiredAt: { type: integer, format: int64 }
        expiresAt: { type: integer, format: int64 }
        expired: { type: boolean }
    TaskEnvelope:
      type: object
      description: >-
        The single-task response envelope. Every single-task endpoint returns
        the task under `task` and never at the top level: create, get, patch,
        move, claim, unlock, board pop, and stage pop, on success and in their
        409 conflict bodies alike. Reading `notes`, `v`, `tags`, or `done` off
        the top level of one of these bodies yields null rather than an error,
        which is how a task holding thousands of characters of notes reads as
        empty. Task lists are the mirror image - see TaskListEnvelope.
      required: [task]
      properties:
        task: { $ref: '#/components/schemas/Task' }
    TaskListEnvelope:
      type: object
      description: >-
        The task-list response envelope. Entries inside `tasks` are bare task
        objects, so a field is read as `tasks[i].notes` - one level shallower
        than TaskEnvelope. Board reads embed their tasks the same bare way,
        under `board.tasks`.
      required: [tasks]
      properties:
        tasks: { type: array, items: { $ref: '#/components/schemas/Task' } }
    Task:
      type: object
      properties:
        id: { type: string }
        stageId: { type: string }
        title: { type: string }
        notes: { type: string, description: Writes are limited to 20,000 Unicode code points; legacy rows may be longer }
        notesJson:
          type: array
          description: Portable export/import only. Native task graphs never include note bodies.
          items:
            type: object
            properties:
              id: { type: string }
              body: { type: string }
              origin: { type: string, enum: [native, legacy, csv, trello, template, restore, offline_conflict] }
              author: { type: string }
              createdAt: { type: integer, format: int64 }
              updatedAt: { type: integer, format: int64 }
              version: { type: integer }
              legacy: { type: boolean }
        notesChecksum: { type: string, description: SHA-256 of the canonical id-independent note content projection }
        notesLossy: { type: boolean, description: True when the single legacy notes string cannot represent the full note set }
        priority: { type: string, enum: [none, low, medium, high] }
        size: { type: string, enum: ['', S, M, L, XL, XXL] }
        color: { type: string }
        tags: { type: array, items: { type: string } }
        lock: { $ref: '#/components/schemas/TaskLock' }
        start: { type: integer, format: int64, nullable: true }
        due: { type: integer, format: int64, nullable: true }
        recurrence: { $ref: '#/components/schemas/Recurrence' }
        url: { type: string }
        repo: { type: string }
        previewImage: { type: string, nullable: true }
        done: { type: boolean }
        v: { type: integer }
        assigneeId: { type: string, nullable: true }
        blockedBy: { type: array, items: { type: string }, description: 'Same-board task ids only; an id that is not a task on this board is rejected 400 BLOCKER_NOT_ON_BOARD unless it is already a stored cross-board edge of this same task, which is retained so a legacy cross-board edge stays editable and removable. A same-board id is never grandfathered — it revalidates against the board the write produces. Cross-board dependencies use a local gate card — see API.md.' }
        customFields: { type: object, additionalProperties: true }
        checklist: { type: array, items: { $ref: '#/components/schemas/ChecklistItem' } }
        attachments: { type: array, items: { $ref: '#/components/schemas/File' } }
        audit: { type: array, items: { $ref: '#/components/schemas/AuditEntry' } }
        createdAt: { type: integer, format: int64 }
        order: { type: number }
        noteCount: { type: integer, readOnly: true, description: Present only when FLUX_TASK_NOTES=1; bodies are served by the notes endpoint }
        latestNoteAt: { type: integer, format: int64, nullable: true, readOnly: true, description: Present only when FLUX_TASK_NOTES=1 }
        hasNotes: { type: boolean, readOnly: true, description: Present only when FLUX_TASK_NOTES=1 }
        workflowId: { type: string, format: uuid, nullable: true, readOnly: true }
        workflowNode: { type: string, nullable: true, readOnly: true }
        workflowVersion: { type: integer, nullable: true, readOnly: true }
        error: { type: string, nullable: true, readOnly: true }
        workflowError:
          readOnly: true
          anyOf:
            - type: 'null'
            - $ref: '#/components/schemas/WorkflowError'
    TaskNote:
      type: object
      description: >
        One independently addressed note record. origin "legacy" is the
        distinguished shadow of the deprecated task-level notes string; its
        author is unknown because the column never recorded one.
      properties:
        id: { type: string, format: uuid }
        taskId: { type: string }
        body: { type: string }
        origin: { type: string, enum: [native, legacy, csv, trello, template, restore, offline_conflict] }
        authorId: { type: string, nullable: true, readOnly: true }
        authorName: { type: string, readOnly: true }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
        v: { type: integer, readOnly: true }
        deletedAt: { type: integer, format: int64, nullable: true, readOnly: true }
        deletedBy: { type: string, nullable: true, readOnly: true }
        conflictedNoteId: { type: string, format: uuid, nullable: true, readOnly: true }
    TaskTemplate:
      type: object
      properties:
        id: { type: string }
        boardId: { type: string }
        name: { type: string }
        task: { $ref: '#/components/schemas/TaskPatch' }
        createdAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64 }
    TaskPatch:
      type: object
      properties:
        stageId: { type: string }
        templateId: { type: string }
        beforeTaskId: { type: string, nullable: true }
        title: { type: string }
        notes: { type: string, maxLength: 20000, description: Measured in Unicode code points; over-limit writes return TASK_NOTES_TOO_LONG }
        priority: { type: string, enum: [none, low, medium, high] }
        size: { type: string, enum: ['', S, M, L, XL, XXL] }
        color: { type: string }
        tags: { type: array, items: { type: string } }
        start: { type: integer, format: int64, nullable: true }
        due: { type: integer, format: int64, nullable: true }
        recurrence: { $ref: '#/components/schemas/Recurrence' }
        url: { type: string }
        repo: { type: string }
        previewImage: { type: string, nullable: true }
        done: { type: boolean }
        assigneeId: { type: string, nullable: true }
        blockedBy: { type: array, items: { type: string }, description: 'Same-board task ids only; an id that is not a task on this board is rejected 400 BLOCKER_NOT_ON_BOARD unless it is already a stored cross-board edge of this same task, which is retained so a legacy cross-board edge stays editable and removable. A same-board id is never grandfathered — it revalidates against the board the write produces. Cross-board dependencies use a local gate card — see API.md.' }
        customFields: { type: object, additionalProperties: true }
        checklist: { type: array, items: { $ref: '#/components/schemas/ChecklistItem' } }
        ifVersion: { type: integer }
    Recurrence:
      type: object
      nullable: true
      properties:
        freq: { type: string, enum: [daily, weekly, monthly, yearly] }
        interval: { type: integer, minimum: 1, maximum: 365, default: 1 }
        count: { type: integer, minimum: 1, maximum: 1000 }
        until: { type: integer, format: int64 }
    ChecklistItem:
      type: object
      properties:
        id: { type: string }
        text: { type: string }
        done: { type: boolean }
    AuditEntry:
      type: object
      properties:
        id: { type: string }
        at: { type: integer, format: int64 }
        userId: { type: string }
        userName: { type: string }
        changes: { type: object, additionalProperties: true }
    File:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string }
        type: { type: string }
        size: { type: integer }
        ownerId: { type: string }
        createdAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64, nullable: true }
        organizational: { type: boolean }
        org:
          anyOf:
            - type: 'null'
            - type: object
              properties:
                id: { type: string }
                name: { type: string }
                email: { type: string }
        links:
          type: array
          items:
            type: object
            properties:
              boardId: { type: string }
              taskId: { type: string, nullable: true }
    FileLinkRequest:
      type: object
      required: [boardId]
      properties:
        boardId: { type: string }
        taskId: { type: string, nullable: true }
    CalendarEvent:
      oneOf:
        - type: object
          properties:
            taskId: { type: string }
            boardId: { type: string }
            boardName: { type: string }
            boardColor: { type: string }
            title: { type: string }
            start: { type: integer, format: int64, nullable: true }
            due: { type: integer, format: int64, nullable: true }
            priority: { type: string }
            done: { type: boolean }
        - type: object
          properties:
            kind: { type: string, const: project }
            boardId: { type: string }
            boardName: { type: string }
            boardColor: { type: string }
            title: { type: string }
            date: { type: integer, format: int64 }
    Preview:
      type: object
      properties:
        url: { type: string }
        image: { type: string, nullable: true }
        title: { type: string, nullable: true }
        description: { type: string, nullable: true }
    AdminUser:
      type: object
      properties:
        id: { type: string }
        email: { type: string }
        name: { type: string }
        createdAt: { type: integer, format: int64 }
        level: { type: string }
        admin: { type: boolean }
        emailVerified: { type: boolean }
        org: { $ref: '#/components/schemas/Org' }
        usage: { $ref: '#/components/schemas/Usage' }
        online: { type: boolean }
        lastSeen: { type: integer, format: int64, nullable: true }
