openapi: 3.0.3
info:
  title: Easerix Tasks API
  description: |
    Linear-style issue tracker (backend/services/products/tasks): teams,
    workflow states, projects, issues, labels, cycles, comments, activity
    history, notifications, and file attachments.

    Tenancy: teams are the tenant root (docs/accounts/TENANCY.md) — every
    other row hangs off team_id, and every query is scoped to the teams of
    the caller's active org. Teams, issues, and anything else outside the
    caller's workspace read as 404, never 403. A fresh org is bootstrapped
    lazily on first request: a "General" (GEN) team with the default
    five-state workflow (Backlog, Todo, In Progress, Done, Canceled), and a
    members row is upserted from the JWT.

    Timestamps are RFC3339 under `created` / `modified` (not createdAt).
    Responds in camelCase (same recorded drift as links/notes — see
    contracts/README.md).

    PATCH semantics: only the fields present in the body change, and an empty
    body is a legal no-op. A body that does not parse as JSON — or that sends a
    field with the wrong type — is a 400; it is never treated as "no fields
    sent", so a request that asked for something never answers 200 without
    having applied it.

    Names are unique per TEAM, not per org: states, projects, labels, and named
    cycles are compared trimmed and case-insensitively within their team, and a
    collision is a 409 on both create and rename. Two teams in one workspace may
    each hold a "Bug" label or a "Done" state.

    Teams themselves are the exception, being the tenant root: a team's name and
    its key are unique per WORKSPACE. Two workspaces may each hold an
    "Engineering" (ENG); one workspace may not hold two.
  version: 1.0.0
  contact:
    name: Perizer Labs
    url: https://easerix.com
servers:
  - url: https://api.easerix.com/tasks
    description: Production
tags:
  - name: system
    description: Health and liveness
  - name: members
    description: The org's member directory (upserted from JWTs)
  - name: teams
    description: Teams — the tenant root
  - name: states
    description: Per-team workflow states
  - name: projects
    description: Per-team projects
  - name: issues
    description: Issues, sub-issues, and label assignment
  - name: labels
    description: Per-team labels
  - name: cycles
    description: Per-team cycles (sprints)
  - name: comments
    description: Issue comments (author-only editing)
  - name: activity
    description: Per-issue property-change history
  - name: attachments
    description: File uploads (R2), served only through the session-checked content route
  - name: imports
    description: >-
      Bringing work in from another tracker (CSV): upload, preview, commit.
      Administrators only.

security:
  - bearer: []

paths:
  /health:
    get:
      operationId: health
      tags: [system]
      summary: Liveness probe
      description: Unauthenticated liveness check used by the platform and monitors.
      security: []
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object

  /health/consumers:
    get:
      operationId: healthConsumers
      tags: [system]
      summary: Event consumer health, for an uptime monitor
      description: >-
        200 while every event consumer is ok, 503 when one is stalled (events
        wait and its cursor hasn't moved) or has open dead letters. Counts
        only. /health stays 200 for pod probes; this one is for the external
        uptime monitor (EAS-467).
      security: []
      responses:
        "200":
          description: Every consumer is ok
          content:
            application/json:
              schema:
                type: object
        "503":
          description: A consumer is stalled or has dead letters
          content:
            application/json:
              schema:
                type: object

  /docs:
    get:
      operationId: apiReference
      tags: [system]
      summary: API reference UI (password-protected)
      description: >-
        Scalar API reference for the running instance, gated by HTTP Basic
        Auth (DOCS_USER/DOCS_PASSWORD) — not linked from any product surface.
        The route is only registered at all when both env vars are set; an
        unconfigured deployment 404s here instead of serving it open.
      security:
        - docsBasic: []
      responses:
        "200":
          description: The reference UI
          content:
            text/html:
              schema:
                type: string

  /docs/openapi.yaml:
    get:
      operationId: openAPISpec
      tags: [system]
      summary: This contract, served by the running instance (password-protected)
      description: >-
        The embedded copy of this OpenAPI document — backs the reference UI
        above and is handy for local codegen against a running instance. Same
        Basic Auth gate as /docs.
      security:
        - docsBasic: []
      responses:
        "200":
          description: The OpenAPI document
          content:
            text/plain:
              schema:
                type: string

  /v1/members:
    get:
      operationId: listMembers
      tags: [members]
      summary: List the workspace's members
      description: >-
        People in the caller's active workspace, ordered by name then email —
        the source for assignee and lead pickers.

        Replicated from the platform event stream, so it lists EVERY member of
        the workspace, including people who have never opened Tasks. It used
        to be built from JWTs as they arrived, which meant a colleague who had
        not signed in here could not be assigned an issue.

        `team` narrows the list to one team's members, which is what an issue's
        assignee picker passes: an issue belongs to a team, so its candidate
        assignees are that team's people. A team the caller cannot reach
        returns an empty list rather than an error. Omit it for
        workspace-level surfaces.

        `q` narrows by a case-insensitive name/email match and caps the
        result at 20 — the @mention suggestion dropdown (EAS-92), which would
        otherwise ship a large workspace's entire roster on every keystroke.
        The unfiltered and team-filtered forms stay uncapped.
      parameters:
        - name: team
          in: query
          required: false
          schema: { type: string }
          description: Limit to the members of this team.
        - name: q
          in: query
          required: false
          schema: { type: string }
          description: Case-insensitive name/email filter, limited to 20 results.
      responses:
        "200":
          description: Members
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items:
                      $ref: "#/components/schemas/Member"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"

  /v1/teams:
    get:
      operationId: listTeams
      tags: [teams]
      summary: List the workspace's teams (read-only — auth owns the entity)
      description: >-
        The teams of the caller's active workspace that the CALLER IS IN — the
        teams named by the teams[] claim in their token, not every team the
        workspace has. Callers holding org.data.read_all (owner, admin) get the
        whole workspace instead, so leadership is never locked out. Personal
        workspaces are never narrowed: they have one member and one
        tasks-local container.

        May be EMPTY, and an empty list is not an error: it means the caller
        is in no team yet. Clients must render that as its own state rather
        than as "no teams exist" — the workspace may be full of teams the
        caller cannot see. Membership changes reach this list when the token
        re-mints (<=1h, immediately on a workspace switch), so a just-created
        team can be missing from one request and present in the next.

        The team ENTITY is platform-wide (the same team Accounts manages,
        owned by the auth service), but this endpoint reads tasks' OWN copy,
        replicated from the platform event stream. It makes no call to auth,
        so auth being unreachable no longer empties this list. A team created
        in Accounts appears here within seconds, with a minted issue-key
        prefix and the default workflow.

        A team DELETED in Accounts leaves this list, while its issues stay
        readable by direct reference — deleting a team must not destroy the
        work filed under it.
      responses:
        "200":
          description: >-
            The teams the caller is in. Possibly empty — see the description.
          content:
            application/json:
              schema:
                type: object
                required: [teams]
                properties:
                  teams:
                    type: array
                    items:
                      $ref: "#/components/schemas/Team"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "502":
          $ref: "#/components/responses/PlatformUnreachable"
  /v1/teams/{id}/settings:
    parameters:
      - $ref: "#/components/parameters/Id"
    get:
      operationId: getTeamSettings
      tags: [teams]
      summary: How a team's board behaves
      description: >-
        The settings Tasks owns for one team. A sub-resource rather than
        fields on the team itself, because the two halves have different
        owners: the identity platform owns the team (its name, its members,
        whether it exists) and Tasks owns only how its board behaves.


        Readable by anyone who can see the team — a client needs the estimate
        scale to render an estimate field at all, and someone who cannot
        change a setting still has to see the board it describes. Changing
        them is the gated half.
      responses:
        "200":
          description: The team's settings
          content:
            application/json:
              schema:
                type: object
                required: [settings]
                properties:
                  settings:
                    $ref: "#/components/schemas/TeamSettings"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateTeamSettings
      tags: [teams]
      summary: Change how a team's board behaves
      description: >-
        Partial: an absent field is left alone, so a client may send only what
        changed and two people editing different settings do not overwrite
        each other. `defaultStateId` additionally distinguishes an empty
        string (clear it, restoring "the team's lowest-position state") from
        omission (leave it).


        `key` is the issue prefix. It is uppercased and trimmed, must be 1-6
        letters or digits, and is unique within the workspace — a clash is a
        409. Changing it changes the displayed id of EVERY issue in the team,
        because an issue's id is its team's key plus its own number: GEN-42
        becomes ENG-42. Existing links that carry the old id will not resolve.


        Every value is validated here before the database's CHECK constraints
        see it, so a bad value is a 400 naming the field rather than a 500.


        `cyclesEnabled` is the only way a team gets cycles at all — there is no
        create route. A background job keeps them running on the cadence: it
        appends the current cycle plus `cycleUpcoming` more, contiguously (or
        `cycleCooldownDays` apart, if set), and never moves, renames or removes
        one that already exists — nothing in this API can delete a cycle once
        the roller has made it. Turning `cyclesEnabled` on from off creates the
        first cycle immediately rather than waiting for the next hourly tick.
        Changing the cadence affects only the cycles created after it — the
        quarter that already happened is not rewritten. `cycleStartDay` aligns
        only the very first cycle; after that each begins `cycleCooldownDays`
        after the previous ends, so the rhythm cannot drift or leave a gap
        beyond the one asked for.


        `cycleRollover` moves an unfinished issue into the next cycle the
        moment its own cycle ends; completed and canceled issues stay where
        they are, since they are that cycle's record of what happened, not
        work still open. Off by default. Turning it on from off closes out
        every cycle that has already ended, so the very next rollover only
        ever affects cycles that end from here on — it does not walk months of
        already-finished cycles forward into the one running now.


        Requires org.teams.manage, or leading this team.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                key:
                  type: string
                  minLength: 1
                  maxLength: 6
                  description: Issue prefix. Uppercased; renames every issue's displayed id.
                defaultStateId:
                  type: string
                  nullable: true
                  description: Where a new issue lands. Empty string restores the lowest-position state.
                defaultPriority:
                  type: integer
                  minimum: 0
                  maximum: 4
                defaultAssignee:
                  type: string
                  enum: [none, creator]
                estimateScale:
                  type: string
                  enum: [none, linear, fibonacci, exponential, tshirt]
                retentionDays:
                  type: integer
                  minimum: 1
                  maximum: 365
                  description: How long Recently Deleted holds a row before the purge erases it.
                cyclesEnabled:
                  type: boolean
                  description: >-
                    Create this team's cycles automatically on the cadence
                    below. Off, this team has no cycles at all — there is no
                    other way to make one.
                cycleWeeks:
                  type: integer
                  minimum: 1
                  maximum: 8
                cycleStartDay:
                  type: integer
                  minimum: 1
                  maximum: 7
                  description: ISO weekday, 1 = Monday … 7 = Sunday. Aligns only the first cycle.
                cycleUpcoming:
                  type: integer
                  minimum: 0
                  maximum: 6
                  description: How many cycles to keep created beyond the current one.
                cycleCooldownDays:
                  type: integer
                  minimum: 0
                  maximum: 6
                  description: >-
                    Days between one cycle ending and the next beginning. 0 (the
                    default) runs them back-to-back.
                cycleRollover:
                  type: boolean
                  description: >-
                    Move an unfinished issue into the next cycle when its own
                    ends. Off by default; completed and canceled issues are
                    never moved.
      responses:
        "200":
          description: The settings after the change
          content:
            application/json:
              schema:
                type: object
                required: [settings]
                properties:
                  settings:
                    $ref: "#/components/schemas/TeamSettings"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /v1/states:
    get:
      operationId: listStates
      tags: [states]
      summary: List workflow states
      description: >-
        Workflow states across the org's teams, ordered by position. Filter
        with team_id (a team outside the org just yields an empty list).
      parameters:
        - $ref: "#/components/parameters/TeamIdFilter"
      responses:
        "200":
          description: States
          content:
            application/json:
              schema:
                type: object
                required: [states]
                properties:
                  states:
                    type: array
                    items:
                      $ref: "#/components/schemas/State"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
    post:
      operationId: createState
      tags: [states]
      summary: Create a workflow state
      description: >-
        Color defaults to "#95a2b3"; position defaults to (max position in the
        team) + 1. The team must belong to the caller's org (else 404 — org
        tenancy). `name` is stored trimmed and must be unique within the team,
        compared case-insensitively — a name already in use is a 409. Two
        different teams may each use the same name. A `type` outside the enum
        is a 400 naming the allowed set — the column carries a CHECK constraint,
        so an unlisted value could never be stored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [teamId, name, type]
              properties:
                teamId:
                  type: string
                  format: uuid
                name:
                  type: string
                type:
                  type: string
                  enum: [backlog, unstarted, started, completed, canceled]
                color:
                  type: string
                position:
                  type: integer
                description:
                  type: string
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [state]
                properties:
                  state:
                    $ref: "#/components/schemas/State"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /v1/states/{id}:
    parameters:
      - $ref: "#/components/parameters/Id"
    patch:
      operationId: updateState
      tags: [states]
      summary: Update a workflow state
      description: >-
        Partial update — only provided fields change. A `name` that another state
        in the same team already uses is a 409; a blank one is a 400. Re-sending
        the state's own name is always allowed. A `type` outside the enum is a
        400 naming the allowed set. Changing `type` away from the team's only
        state of that category is a 409, same rule DELETE already enforces —
        re-sending the state's own current type is always allowed, even when it
        is that only state.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                type:
                  type: string
                  enum: [backlog, unstarted, started, completed, canceled]
                color:
                  type: string
                position:
                  type: integer
                description:
                  type: string
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [state]
                properties:
                  state:
                    $ref: "#/components/schemas/State"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
    delete:
      operationId: deleteState
      tags: [states]
      summary: Delete a workflow state
      description: >-
        Hard delete, and only when nothing points at the state. Issues carry a
        NOT NULL state, so a state that still has issues is a 409 naming how
        many — move them to another state first, then delete it. Also a 409
        when this is the last state of its `type` in the team: progress bars,
        completion %, and My Issues' active filter all read a state's type,
        not its name, so a category (e.g. every `completed` state) left empty
        would silently stop working rather than fail loudly. Create or keep
        another state of the same type first.
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /v1/projects:
    get:
      operationId: listProjects
      tags: [projects]
      summary: List projects
      description: Projects across the org's teams, newest first. Filter with team_id.
      parameters:
        - $ref: "#/components/parameters/TeamIdFilter"
        - name: deleted
          in: query
          description: >-
            When true, returns ONLY soft-deleted projects - the Recently
            Deleted view. Every other view excludes them. A project is erased
            for good 30 days after deletion, or sooner via
            permanentlyDeleteProject.
          schema:
            type: boolean
      responses:
        "200":
          description: Projects
          content:
            application/json:
              schema:
                type: object
                required: [projects]
                properties:
                  projects:
                    type: array
                    items:
                      $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
    post:
      operationId: createProject
      tags: [projects]
      summary: Create a project
      description: >-
        Status defaults to "planned". leadId must reference a member of the
        caller's org (else 400 "invalid leadId"); an empty-string leadId is
        treated as null. targetDate accepts YYYY-MM-DD (or RFC3339, truncated
        to the day). The team must belong to the caller's org (else 404). `name`
        is stored trimmed and must be unique within the team, compared
        case-insensitively — a name already in use is a 409. A `status` outside
        the enum is a 400 naming the allowed set — the column carries a CHECK
        constraint, so an unlisted value could never be stored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [teamId, name]
              properties:
                teamId:
                  type: string
                  format: uuid
                name:
                  type: string
                description:
                  type: string
                status:
                  type: string
                  enum: [planned, in_progress, paused, completed, canceled]
                leadId:
                  type: string
                  format: uuid
                  nullable: true
                targetDate:
                  type: string
                  format: date
                  nullable: true
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project:
                    $ref: "#/components/schemas/Project"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /v1/projects/{id}:
    parameters:
      - $ref: "#/components/parameters/Id"
    get:
      operationId: getProject
      tags: [projects]
      summary: Get a project
      description: One project (404 when it belongs to another org).
      responses:
        "200":
          description: The project
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project:
                    $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateProject
      tags: [projects]
      summary: Update a project
      description: >-
        Partial update. Setting leadId to "" clears the lead; a non-empty
        leadId outside the org's members is 400 "invalid leadId". A `name` that
        another project in the same team already uses is a 409; a blank one is a
        400. Re-sending the project's own name is always allowed. A `status`
        outside the enum is a 400 naming the allowed set.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                description:
                  type: string
                status:
                  type: string
                  enum: [planned, in_progress, paused, completed, canceled]
                leadId:
                  type: string
                  nullable: true
                  description: Empty string clears the lead.
                targetDate:
                  type: string
                  format: date
                  nullable: true
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project:
                    $ref: "#/components/schemas/Project"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
    delete:
      operationId: deleteProject
      tags: [projects]
      summary: Delete a project
      description: >-
        Soft delete. The project moves to Recently Deleted (list it with
        deleted=true) together with every live issue in it that belongs to the
        project's OWN team - the cascade is bounded to that team because
        permission was checked against the project row, so it must not reach a
        row that check never covered. Deleting an already-deleted project is a
        404. Nothing is erased until permanentlyDeleteProject is called or the
        30-day retention window expires.
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/projects/{id}/restore:
    parameters:
      - $ref: "#/components/parameters/Id"
    post:
      operationId: restoreProject
      tags: [projects]
      summary: Restore a deleted project
      description: >-
        Moves a soft-deleted project out of Recently Deleted, and with it every
        issue currently pointing at it that belongs to the project's own team -
        unconditionally, regardless of when each issue was deleted, so restore
        is the exact mirror of the delete it undoes. A project that is not
        soft-deleted is a 404, as is one belonging to another org.
      responses:
        "200":
          description: The restored project
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project:
                    $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/projects/{id}/permanent:
    parameters:
      - $ref: "#/components/parameters/Id"
    delete:
      operationId: permanentlyDeleteProject
      tags: [projects]
      summary: Permanently delete a project
      description: >-
        Erases the project row for good. Only valid on a project already in
        Recently Deleted - anything else, including a live project, is a 404.
        Issues sitting in Recently Deleted are NOT erased with it: permanent
        deletion never cascades destruction downward, and issues.project_id is
        ON DELETE SET NULL, so they simply lose the link and stay
        independently restorable.
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues:
    get:
      operationId: listIssues
      tags: [issues]
      summary: List issues
      description: >-
        Issues across the teams the CALLER IS IN (see listTeams for how that
        set is decided), ordered by position, then number, then id. Each row
        OMITS description: descriptions are unbounded markdown and no list
        renders one, so read it from getIssue instead. Filters
        combine with AND; q is a case-insensitive title substring match (LIKE
        wildcards in q are escaped and match literally), or with scope=all a
        full-text search over titles, descriptions and comments.


        KEYSET PAGED. Send limit to choose a page size and pass the nextCursor
        from the previous response to continue. nextCursor is present only while
        more rows remain, so its absence means the end of the list rather than a
        truncation the caller was never told about. total counts every row the
        filters match, not the page, because counts and progress in the UI are
        drawn from it and a page cannot say how many there are.


        Cursors are opaque and must not be constructed or parsed by clients: the
        sort key they encode is free to change. A malformed cursor is a 400
        rather than a silent restart, which would re-deliver the whole list.


        limit defaults to 50. The old behaviour of returning up to 1000 rows in
        one response is still available by asking for it.


        An issue in a team the caller is not in is invisible everywhere, not
        just here: reading one by id is a 404, never a 403, so the response
        does not confirm that it exists.
      parameters:
        - $ref: "#/components/parameters/TeamIdFilter"
        - name: limit
          in: query
          description: >-
            Page size. Defaults to 50 and is capped at 1000. A caller that
            needs the whole set in one response must ask for it explicitly.
            A full-text search (scope=all) is capped at 50 instead: each row
            costs a description and its matching comments to check, so a larger
            value is clamped to 50, and the response reports the size applied.
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 50
        - name: cursor
          in: query
          description: >-
            Opaque nextCursor from a previous response. Continues after the last
            row that response returned.
          schema:
            type: string
        - name: project_id
          in: query
          description: Repeatable. Matches any of the given project ids.
          schema:
            type: array
            items:
              type: string
              format: uuid
        - name: state_id
          in: query
          description: Repeatable. Matches any of the given state ids.
          schema:
            type: array
            items:
              type: string
              format: uuid
        - name: assignee_id
          in: query
          description: Repeatable. Matches any of the given member ids.
          schema:
            type: array
            items:
              type: string
              format: uuid
        - name: cycle_id
          in: query
          schema:
            type: string
            format: uuid
        - name: parent_id
          in: query
          schema:
            type: string
            format: uuid
        - name: sort
          in: query
          description: >-
            Ordering. `manual` is the hand-sorted position order and the default.
            `priority` puts urgent first and "none" (0) LAST. `newest` and
            `updated` are most-recent-first. `due` is soonest first with undated
            issues last. A cursor is only valid for the sort that produced it.


            `relevance` ranks a full-text search, best first, and is a 400
            without scope=all and q. Results fall into three tiers: every word
            in the title or is the issue key, then every word in the title or
            description, then the rest (a word only in a comment). Within a tier
            the most recently updated issue comes first. The tiers are judged on
            the stored text, so an issue whose only description match is inside a
            link can sit a tier higher than it reads; whether it appears at all
            is still decided by the exact check. The response reports
            sort=relevance.
          schema:
            type: string
            enum: [manual, priority, newest, updated, due, relevance]
            default: manual
        - name: priority
          in: query
          description: Repeatable. 0 none, 1 urgent, 2 high, 3 medium, 4 low.
          schema:
            type: array
            items:
              type: integer
              minimum: 0
              maximum: 4
        - name: label_id
          in: query
          description: >-
            Repeatable, OR semantics: an issue carrying ANY of these labels
            matches. An issue matching several is still returned once.
          schema:
            type: array
            items:
              type: string
              format: uuid
        - name: state_type
          in: query
          description: >-
            Repeatable. Filters on the state's TYPE rather than its id, because
            every team names and numbers its own states while the types are
            shared.
          schema:
            type: array
            items:
              type: string
              enum: [backlog, unstarted, started, completed, canceled]
        - name: creator_id
          in: query
          schema:
            type: string
            format: uuid
        - name: due_before
          in: query
          description: >-
            Inclusive. Issues with no due date never match. Combine with
            state_type to express "due soon and still open".
          schema:
            type: string
            format: date
        - name: unassigned
          in: query
          description: >-
            When true, returns only issues with no assignee, and assignee_id is
            ignored. A flag rather than a sentinel id, so no real id can ever
            collide with it.
          schema:
            type: boolean
        - name: assigned_to_me
          in: query
          description: >-
            When true, returns only issues assigned to the caller. Resolved
            from the request, so a client does not need its own member id. A
            flag rather than a sentinel id, like unassigned. Combines with
            assignee_id (both must match) but not with unassigned, which is a
            400.
          schema:
            type: boolean
        - name: no_project
          in: query
          description: When true, returns only issues in no project, and project_id is ignored.
          schema:
            type: boolean
        - name: deleted
          in: query
          description: >-
            When true, returns ONLY soft-deleted issues - the Recently Deleted
            view. Every other view excludes them. An issue is erased for good
            30 days after deletion, or sooner via permanentlyDeleteIssue.
          schema:
            type: boolean
        - name: blocked
          in: query
          description: >-
            When true, returns only issues that have an open blocker: a blocks
            relation whose source issue is not soft-deleted and whose state
            type is backlog, unstarted, or started. A completed or canceled
            blocker does not match. The same rule as blockedByCount.
          schema:
            type: boolean
        - name: q
          in: query
          description: >-
            Case-insensitive. With scope=title (the default) matches the title,
            or the issue's human key ("ENG-42") when the query looks like one.
            With scope=all it is a full-text query, see scope.
          schema:
            type: string
        - name: scope
          in: query
          description: >-
            What q is matched against. `title` (default) is the title and issue
            key only, and behaves exactly as it always has.


            `all` also searches descriptions and comments, and each returned
            issue carries a `match` saying where and why it matched. q is split
            into words, every word must be found (in any of the title,
            description or a comment, not necessarily the same one), and a
            "quoted phrase" is one word that must appear contiguously. Matching
            ignores case and markdown syntax: the text of a mention or link is
            searched, its id and URL are not. At most 5 words, each at least 2
            characters, and at most 200 characters overall; anything else is a
            400. An unknown scope is a 400 even without q.


            In descriptions and comments a word is found by what it starts with:
            `log` finds "login", but `ogin` does not find it. The parts of an
            address, a path or a name joined by punctuation ("john.smith@x.com",
            "snake_case") are words of their own. Titles are not matched this way
            and keep matching any part of a word, so `ogin` still finds a title
            that says "login". Only the first 50,000 characters of a text are
            searched.


            A search can be narrowed on a term only if it has a run of at least 3
            letters or digits: "login" and "log-in" do, "lo", "ui/ux" and "to-do"
            do not. A query with even ONE term it cannot narrow is answered by
            reading every issue in the caller's teams and their comments,
            whatever the other terms are: "lo login" is not helped by "login". It
            is valid, but it is held to 1 second instead of 3, and a client
            should run it when the person asks for it rather than on every
            keystroke. Past the limit the answer is a 503 if nothing has been
            found yet, and otherwise what was found, as a 200 with a nextCursor.


            A scope=all response has NO `total` (the cheap count would include
            candidates the exact check later drops, so it could contradict the
            results), and its nextCursor is bound to the query: a cursor from
            one search is a 400 on another, and on an ordinary list.
          schema:
            type: string
            enum: [title, all]
            default: title
      responses:
        "200":
          description: One page of issues
          content:
            application/json:
              schema:
                type: object
                required: [issues, limit, sort]
                properties:
                  issues:
                    type: array
                    maxItems: 1000
                    items:
                      $ref: "#/components/schemas/Issue"
                  total:
                    type: integer
                    format: int64
                    description: >-
                      Rows matching the filters, ignoring paging. Draw counts and
                      progress from this, never from issues.length. Present only
                      on the FIRST page (no cursor): a client needs it once, and
                      recomputing it per scroll step would double the cost of
                      paging. Never present for scope=all, whose exact check
                      happens after the page is read, so no count taken before it
                      could be right. For counts per group, use countIssues.
                  limit:
                    type: integer
                    description: Page size actually applied, after clamping.
                  sort:
                    type: string
                    description: Ordering actually applied, after falling back on an unknown value.
                  nextCursor:
                    type: string
                    description: >-
                      Pass as cursor to fetch the next page. Absent when this is
                      the last page. For scope=all a page can hold fewer than
                      limit rows and still carry a nextCursor, because rows that
                      matched only markdown syntax are dropped after the page is
                      read: keep following it until it is absent.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "503":
          description: >-
            scope=all only: the search ran past its time limit before finding
            anything. Retry with fewer or more specific words. A search that
            runs out of time AFTER finding some results is not an error: it
            returns those results as a 200, usually fewer than limit, with a
            nextCursor to carry on from.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      operationId: createIssue
      tags: [issues]
      summary: Create an issue
      description: >-
        The issue number is per-team incrementing (max(number)+1, computed in
        a transaction) and position starts equal to the number. State
        resolution: an explicit stateId must belong to the target team (else
        400 "stateId does not belong to that team"); omitted, the team's
        lowest-position state is used; a team with no states at all is 400
        ("no workflow state available; create a state first"). priority
        defaults to 0 (0 none, 1 urgent, 2 high, 3 medium, 4 low); estimate 0
        means "no estimate" and is stored as null. assigneeId must be an org
        member; projectId/cycleId/parentId must reference rows inside the
        caller's org (else 400 "invalid …Id"). Records a "created" activity
        and notifies the assignee (import-mode creates do not notify). The
        creator and the assignee are seeded as followers of the issue, on
        imported creates too: seeding notifies nobody, it only decides who
        hears about later activity. A
        description containing @[Name](mention:memberId) tokens is processed
        the same as on a comment (see POST /v1/issues/{id}/comments) — each
        resolvable mention notifies and follows, even though the description
        arrived at creation rather than through a later edit.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [teamId, title]
              properties:
                teamId:
                  type: string
                  format: uuid
                projectId:
                  type: string
                  format: uuid
                  nullable: true
                title:
                  type: string
                description:
                  type: string
                stateId:
                  type: string
                  format: uuid
                  nullable: true
                priority:
                  type: integer
                  description: 0 none, 1 urgent, 2 high, 3 medium, 4 low.
                assigneeId:
                  type: string
                  format: uuid
                  nullable: true
                parentId:
                  type: string
                  format: uuid
                  nullable: true
                cycleId:
                  type: string
                  format: uuid
                  nullable: true
                estimate:
                  type: integer
                  nullable: true
                  description: 0 means "no estimate" (stored as null).
                dueDate:
                  type: string
                  format: date
                  nullable: true
                  description: YYYY-MM-DD; a malformed value is 400.
                createdAt:
                  type: string
                  format: date-time
                  description: >-
                    Import mode (org owner/admin only, else 403 permission_denied,
                    required: org.settings.manage): historical creation
                    timestamp preserved from a source tracker. Also sets
                    updatedAt. Imported issues do not notify.
                completedAt:
                  type: string
                  format: date-time
                  description: >-
                    Import mode (org owner/admin only, else 403
                    permission_denied, required: org.settings.manage).
                creatorEmail:
                  type: string
                  format: email
                  description: >-
                    Import mode (org owner/admin only, else 403
                    permission_denied, required: org.settings.manage):
                    attribute the issue to the org member with this email;
                    unknown emails fall back to the caller.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [issue]
                properties:
                  issue:
                    $ref: "#/components/schemas/Issue"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/counts:
    get:
      operationId: countIssues
      tags: [issues]
      summary: Grouped issue counts
      description: >-
        Totals for the issues the caller can see, grouped, honouring every filter
        listIssues honours because it shares the same query builder. A count
        walking a different predicate than the list beside it would put two
        numbers on screen that disagree, with neither looking wrong alone.


        Exists because a paged list cannot answer "how many". Group headers,
        board columns and the progress bars on the cycles and projects pages each
        need a real total per group, and one grouped query serves a whole screen
        where per-group requests would take one per column.


        Each group carries `total` and `done` - both, because a progress bar
        needs two numbers and fetching them separately would double the requests
        per bar. `done` counts issues whose state is of the `completed` type.


        `key` is always a string, including for priority, which groups on an
        integer column. A null key is a real group: unassigned, no project, or no
        cycle. Groups holding no issues are ABSENT rather than zero - the caller
        knows its own states, projects and cycles and can render those as zero,
        which the server cannot do for assignees.
      parameters:
        - $ref: "#/components/parameters/TeamIdFilter"
        - name: group_by
          in: query
          required: true
          schema:
            type: string
            enum: [state, priority, assignee, project, cycle]
        - name: blocked
          in: query
          description: >-
            Same flag as listIssues. Counts only issues with an open blocker,
            so a column total cannot disagree with the cards under it.
          schema:
            type: boolean
        - name: scope
          in: query
          description: >-
            Same as listIssues, together with q. With scope=all the counts are
            of CANDIDATES: issues whose stored text contains every word, which
            can include a few that listIssues later drops because they matched
            only markdown syntax. Use them as facets beside a results list, not
            as its exact length.
          schema:
            type: string
            enum: [title, all]
            default: title
      responses:
        "200":
          description: Counts per group
          content:
            application/json:
              schema:
                type: object
                required: [groups, total, done]
                properties:
                  groups:
                    type: array
                    items:
                      type: object
                      required: [key, total, done]
                      properties:
                        key:
                          type: string
                          nullable: true
                        total:
                          type: integer
                          format: int64
                        done:
                          type: integer
                          format: int64
                  total:
                    type: integer
                    format: int64
                  done:
                    type: integer
                    format: int64
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "503":
          description: >-
            scope=all only: the count ran past its time limit. Retry with fewer
            or more specific words.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/issues/{id}:
    parameters:
      - $ref: "#/components/parameters/Id"
    get:
      operationId: getIssue
      tags: [issues]
      summary: Get an issue
      description: The issue with state, assignee, and labels embedded.
      responses:
        "200":
          description: The issue
          content:
            application/json:
              schema:
                type: object
                required: [issue]
                properties:
                  issue:
                    $ref: "#/components/schemas/Issue"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateIssue
      tags: [issues]
      summary: Update an issue
      description: >-
        Partial update. Empty string clears assigneeId / projectId / cycleId /
        parentId; estimate 0 clears the estimate; a malformed dueDate is 400
        (never treated as "clear"). stateId must belong to the issue's team
        (else 400 "stateId does not belong to this issue's team"); moving to a
        completed-type state sets completedAt, moving anywhere else clears it.
        Property changes are recorded as activity entries, and an assignee
        change notifies the new assignee.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                description:
                  type: string
                stateId:
                  type: string
                  format: uuid
                priority:
                  type: integer
                  description: 0 none, 1 urgent, 2 high, 3 medium, 4 low.
                assigneeId:
                  type: string
                  nullable: true
                  description: Empty string clears the assignee.
                projectId:
                  type: string
                  nullable: true
                  description: Empty string clears the project.
                parentId:
                  type: string
                  nullable: true
                  description: Empty string clears the parent.
                cycleId:
                  type: string
                  nullable: true
                  description: Empty string clears the cycle.
                estimate:
                  type: integer
                  nullable: true
                  description: 0 clears the estimate.
                position:
                  type: number
                  description: >-
                    Sets the manual-order value directly. Prefer afterId: a
                    dragging client can always name the row it dropped below,
                    but computing this number also needs the row on the OTHER
                    side, which a paged client cannot see. Kept for undo, which
                    restores a value it already holds. Sending both is a 400.
                afterId:
                  type: string
                  description: >-
                    Places the issue immediately after this one in the team's
                    manual order, letting the SERVER derive the position from the
                    row that actually follows it. An empty string means the top
                    of the list; omitted leaves the order untouched.


                    The anchor must be in the same team, since manual order is
                    per team. An anchor in another team, or outside the caller's
                    workspace, is a 400.


                    Combine with stateId in one request when a board drag changes
                    both the column and the order, so a half-failure cannot leave
                    the card in the new column at the old position.
                dueDate:
                  type: string
                  format: date
                  nullable: true
                  description: YYYY-MM-DD; a malformed value is 400.
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [issue]
                properties:
                  issue:
                    $ref: "#/components/schemas/Issue"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deleteIssue
      tags: [issues]
      summary: Delete an issue
      description: >-
        Soft delete. The issue moves to Recently Deleted (list it with
        deleted=true) along with every sub-issue at any depth - none is left
        live and none is orphaned. Comments and attachments are untouched,
        because nothing about the issue is actually gone yet. A descendant that
        was already deleted keeps its original timestamp, so its own 30-day
        clock is not reset. Deleting an already-deleted issue is a 404.
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/restore:
    parameters:
      - $ref: "#/components/parameters/Id"
    post:
      operationId: restoreIssue
      tags: [issues]
      summary: Restore a deleted issue
      description: >-
        Moves a soft-deleted issue out of Recently Deleted together with its
        whole sub-issue subtree, unconditionally - including a sub-issue that
        was deleted separately beforehand, so restore is the exact mirror of
        the delete it undoes. An issue that is not soft-deleted is a 404, as is
        one in a team the caller is not in.
      responses:
        "200":
          description: The restored issue
          content:
            application/json:
              schema:
                type: object
                required: [issue]
                properties:
                  issue:
                    $ref: "#/components/schemas/Issue"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/permanent:
    parameters:
      - $ref: "#/components/parameters/Id"
    delete:
      operationId: permanentlyDeleteIssue
      tags: [issues]
      summary: Permanently delete an issue
      description: >-
        Erases the issue row for good, and only this row: a sub-issue still
        sitting in Recently Deleted is untouched and stays independently
        restorable or purgeable. Comments cascade for real here, and the
        issue's attachments are marked for the sweeper to reclaim their files -
        this is the first point the issue is genuinely gone. Only valid on an
        issue already in Recently Deleted; anything else, including a live
        issue, is a 404.
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/relations:
    get:
      operationId: listIssueRelations
      tags: [issues]
      summary: List an issue's relations
      description: >-
        Both directions of every link on this issue. blocks is one stored row:
        the source is holding the target up, and the target reads that same row
        as blocked by. A completed or canceled blocker is returned under
        resolvedBlocks rather than blockedBy, and the row stays type blocks so
        reopening the blocker counts again. related is one row shown on both
        issues. duplicateOf is set when this issue is the copy. A soft-deleted
        other end is omitted until that issue is restored.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Relations grouped by what they mean on this issue
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IssueRelations"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      operationId: createIssueRelation
      tags: [issues]
      summary: Link this issue to another
      description: >-
        Idempotent. The same pair and type returns the existing row with
        created false and writes nothing. blocked_by is stored as blocks with
        the two ids swapped. related is stored with the lower id as the source
        so asking from either issue is the same row. Both issues must be in
        this issue's team. A blocks link that would close a loop is refused, and so
        is a duplicate link that would loop back on itself. An issue can be a
        duplicate of only one other issue.
      parameters:
        - $ref: "#/components/parameters/Id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [relatedIssueId, type]
              properties:
                relatedIssueId:
                  type: string
                  format: uuid
                type:
                  type: string
                  enum: [blocks, blocked_by, related, duplicate]
      responses:
        "200":
          description: The stored relation. created is false when it already existed.
          content:
            application/json:
              schema:
                type: object
                required: [relation, created]
                properties:
                  relation:
                    $ref: "#/components/schemas/IssueRelation"
                  created:
                    type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /v1/issue-relations/{id}:
    delete:
      operationId: deleteIssueRelation
      tags: [issues]
      summary: Remove a relation
      description: >-
        Deletes the link only. Both issues stay. Either end the caller cannot
        currently see — including a soft-deleted one — is 404.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/labels:
    post:
      operationId: addIssueLabel
      tags: [issues]
      summary: Add a label to an issue
      description: >-
        Idempotent (re-adding is a no-op). Both the issue and the label must
        be inside the caller's org (else 404). Returns 200 (not 201) with the
        full updated issue.
      parameters:
        - $ref: "#/components/parameters/Id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [labelId]
              properties:
                labelId:
                  type: string
                  format: uuid
      responses:
        "200":
          description: The updated issue
          content:
            application/json:
              schema:
                type: object
                required: [issue]
                properties:
                  issue:
                    $ref: "#/components/schemas/Issue"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/labels/{labelId}:
    delete:
      operationId: removeIssueLabel
      tags: [issues]
      summary: Remove a label from an issue
      description: Returns the full updated issue (removing an absent label is a no-op).
      parameters:
        - $ref: "#/components/parameters/Id"
        - name: labelId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The updated issue
          content:
            application/json:
              schema:
                type: object
                required: [issue]
                properties:
                  issue:
                    $ref: "#/components/schemas/Issue"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/followers:
    get:
      operationId: listFollowers
      tags: [issues]
      summary: List an issue's followers
      description: >-
        Everyone currently subscribed to this issue's notifications, oldest
        subscription first. Excludes anyone who followed and then explicitly
        unfollowed (DELETE /v1/issues/{id}/followers/me), and anyone whose
        member row was removed from the workspace.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Followers
          content:
            application/json:
              schema:
                type: object
                required: [followers]
                properties:
                  followers:
                    type: array
                    items:
                      $ref: "#/components/schemas/ActorRef"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/followers/me:
    parameters:
      - $ref: "#/components/parameters/Id"
    put:
      operationId: followIssue
      tags: [issues]
      summary: Follow an issue
      description: >-
        Subscribes the calling member to this issue's notifications.
        Idempotent — following an issue you already follow is a no-op, and
        re-following one you previously left clears that opt-out.
      responses:
        "200":
          description: Current subscription state
          content:
            application/json:
              schema:
                type: object
                required: [following]
                properties:
                  following:
                    type: boolean
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: unfollowIssue
      tags: [issues]
      summary: Unfollow an issue
      description: >-
        Unsubscribes the calling member from this issue's notifications.
        Sticks even against a later automatic re-subscription (a new
        @mention or reassignment) — unfollowing is a deliberate opt-out, not
        a one-time snooze.
      responses:
        "200":
          description: Current subscription state
          content:
            application/json:
              schema:
                type: object
                required: [following]
                properties:
                  following:
                    type: boolean
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/comments:
    parameters:
      - $ref: "#/components/parameters/Id"
    get:
      operationId: listComments
      tags: [comments]
      summary: List an issue's comments
      description: Oldest first, with the author embedded for direct rendering.
      responses:
        "200":
          description: Comments
          content:
            application/json:
              schema:
                type: object
                required: [comments]
                properties:
                  comments:
                    type: array
                    items:
                      $ref: "#/components/schemas/Comment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      operationId: createComment
      tags: [comments]
      summary: Comment on an issue
      description: >-
        Notifies everyone following the issue, minus the author. That is a
        wider audience than creator and assignee: those two are seeded as
        followers when the issue is created, but so is anyone later mentioned
        or assigned, and anyone who followed it themselves. A follower who has
        unsubscribed, or who is no longer on the issue's team, is excluded.
        A body containing @[Name](mention:memberId) tokens for members who can
        reach this issue additionally notifies each of them ("mentioned"
        instead of the generic "commented" when they would otherwise get both)
        and adds them as a follower of the issue. Imported comments
        (createdAt/authorEmail set) do not notify or process mentions.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body:
                  type: string
                createdAt:
                  type: string
                  format: date-time
                  description: >-
                    Import mode (org owner/admin only, else 403
                    permission_denied, required: org.settings.manage):
                    historical timestamp preserved from a source tracker.
                authorEmail:
                  type: string
                  format: email
                  description: >-
                    Import mode (org owner/admin only, else 403
                    permission_denied, required: org.settings.manage):
                    attribute the comment to the org member with this email;
                    unknown emails fall back to the caller.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [comment]
                properties:
                  comment:
                    $ref: "#/components/schemas/Comment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/comments/{id}:
    parameters:
      - $ref: "#/components/parameters/Id"
    patch:
      operationId: updateComment
      tags: [comments]
      summary: Edit a comment (author-only)
      description: >-
        Only the comment's author may edit it — anyone else in the org gets
        403 ("only the author can edit a comment"), an administrator
        included. Unlike deleteComment there is deliberately NO moderator
        override here: removing someone's words is moderation, rewriting them
        under their own name is not. Editing sets the comment's modified
        timestamp (null until first edit — the "(edited)" marker).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body:
                  type: string
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [comment]
                properties:
                  comment:
                    $ref: "#/components/schemas/Comment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The tasks tool is turned off for the caller's organization, or
            the caller is not the comment's author ("only the author can edit
            a comment")
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deleteComment
      tags: [comments]
      summary: Delete a comment (author, or a workspace admin)
      description: >-
        The comment's author may delete it, and so may a moderator — someone
        holding org.teams.manage, or leading the team the comment's issue
        belongs to. That is the path for removing a comment nobody else can,
        such as one carrying a pasted credential.


        Note the asymmetry with updateComment, which stays author-only with no
        override: an administrator may REMOVE someone's words but never
        rewrite them, because an edited comment still carries the original
        author's name.


        A moderator deletion is recorded in the audit log as
        `comment.moderated`; deleting your own is not, since that is ordinary
        use and would bury the entries that matter.


        Reach is bounded by the comment's issue, so a comment in another
        workspace is 404 for everyone including an admin — never 403, which
        would confirm the id names a real comment somewhere.
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The tasks tool is turned off for the caller's organization, or the
            caller is neither the author nor a moderator ("only the author, a
            workspace admin, or this team's lead can delete a comment")
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/activities:
    get:
      operationId: listActivities
      tags: [activity]
      summary: List an issue's activity history
      description: >-
        Property-change entries, oldest first, capped at 500, with the actor
        embedded. from/to hold raw values (ids, numbers, dates) that clients
        resolve for display.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Activities (at most 500)
          content:
            application/json:
              schema:
                type: object
                required: [activities]
                properties:
                  activities:
                    type: array
                    maxItems: 500
                    items:
                      $ref: "#/components/schemas/Activity"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/attachments:
    get:
      operationId: listIssueAttachments
      tags: [attachments]
      summary: List an issue's attachments
      description: >-
        Newest first. Each url is GET /v1/attachments/{id}/content, which serves the bytes
        to a caller with a session.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Attachments
          content:
            application/json:
              schema:
                type: object
                required: [attachments]
                properties:
                  attachments:
                    type: array
                    items:
                      $ref: "#/components/schemas/Attachment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  # Tasks notifications moved to the platform notifications service. Tasks now
  # emits `tasks.*` events to the platform outbox (backend/packages/eventkit)
  # and the shared feed/bell (notifications-api.easerix.com) serves them, so
  # there are no notification routes on the tasks API. See docs/notifications/PLAN.md.

  /v1/attachments:
    post:
      operationId: createAttachment
      tags: [attachments]
      summary: Upload a file (multipart, "file" field, ≤15MB)
      description: >-
        multipart/form-data upload to R2. Any linked issue (directly via
        issueId, or via the target comment's issue) must belong to the
        caller's org (else 404). 400 when the file is missing or larger than
        ~15MB; 501 when storage is not configured. Returns 200 (not 201).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                issueId:
                  type: string
                  format: uuid
                commentId:
                  type: string
                  format: uuid
      responses:
        "200":
          description: Uploaded
          content:
            application/json:
              schema:
                type: object
                required: [attachment]
                properties:
                  attachment:
                    $ref: "#/components/schemas/Attachment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "501":
          $ref: "#/components/responses/StorageUnconfigured"

  /v1/attachments/{id}:
    delete:
      operationId: deleteAttachment
      tags: [attachments]
      summary: Delete an attachment and its stored file
      description: >-
        Authenticated and org-scoped: holding the link must not be enough to
        destroy the file. Removes the object from storage inline, so the file
        stops being served at once.


        Reach follows the parent — the attachment's issue, or its comment's
        issue — so an attachment outside the caller's workspace answers 404,
        never 403. So does one already deleted, which makes a repeated delete
        idempotent, and one whose parent is already gone: there is no workspace
        left to authorize it, and a background sweeper reclaims those.


        200 even when storage refuses the delete: the attachment has left every
        view the caller can see, and the row stays marked so the sweeper
        finishes the file. 501 when storage is not configured, since marking the
        row would hide the only record of a file that could never be removed.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "501":
          $ref: "#/components/responses/StorageUnconfigured"

  /v1/attachments/{id}/content:
    get:
      operationId: getAttachmentContent
      tags: [attachments]
      summary: Stream an attachment's bytes (session-checked)
      description: >-
        Streams the file itself to an authenticated caller who can read the
        issue it hangs off — the attachment's issue, or the issue its comment
        sits on: the caller's active workspace, then a team they may read
        (EAS-267). The web editor loads images through this route into blob:
        URLs, so no URL that opens a file on its own has to leave the service.


        Everything out of reach is a 404, never a 403 — another workspace,
        another team's file, a deleted attachment, an orphan whose parent is
        gone — so the response never confirms that an id names a real file.


        Inline (with the stored image type) only for verified raster images;
        every other file is served as a download under its own filename. The
        response is `private, no-store`, `no-referrer`, `nosniff`, and carries
        `Content-Security-Policy: default-src 'none'; sandbox`.


        Streams are bounded: a deadline per stream sized to the file (30 s
        plus one second per 128 KiB), at most 12 concurrent streams per caller
        and 128 overall. A request past a cap waits up to 10 seconds for a
        slot, then gets 429 with Retry-After. 501 when storage is not
        configured; 502 when storage cannot be read.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: The file's bytes
          headers:
            Content-Disposition:
              description: "`inline` for verified raster images, otherwise `attachment` with the filename."
              schema:
                type: string
            Cache-Control:
              schema:
                type: string
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "501":
          $ref: "#/components/responses/StorageUnconfigured"
        "502":
          description: Storage could not be read
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/labels:
    get:
      operationId: listLabels
      tags: [labels]
      summary: List labels
      description: >-
        Labels across the org's teams, ordered by name. Filter with team_id.
        Each carries issueCount, the number of non-deleted issues that still
        have it, so a caller can weigh a delete before making one.
      parameters:
        - $ref: "#/components/parameters/TeamIdFilter"
      responses:
        "200":
          description: Labels
          content:
            application/json:
              schema:
                type: object
                required: [labels]
                properties:
                  labels:
                    type: array
                    items:
                      $ref: "#/components/schemas/Label"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
    post:
      operationId: createLabel
      tags: [labels]
      summary: Create a label
      description: >-
        Color defaults to "#95a2b3". The team must belong to the caller's org
        (else 404). `name` is stored trimmed and must be unique within the team,
        compared case-insensitively — a name already in use is a 409, whatever
        colour it is sent with. Two different teams may each use the same name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [teamId, name]
              properties:
                teamId:
                  type: string
                  format: uuid
                name:
                  type: string
                color:
                  type: string
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [label]
                properties:
                  label:
                    $ref: "#/components/schemas/Label"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /v1/labels/{id}:
    patch:
      operationId: updateLabel
      tags: [labels]
      summary: Update a label
      description: >-
        Rename or recolour. Omitted fields keep their stored value. A label's
        team is immutable, so teamId is not accepted — moving a label would
        detach it from every issue that carries it. A name another label in the
        same team uses is a 409, compared trimmed and case-insensitively; a
        label keeping its own name is not a collision. Unlike a cycle, a label
        has no number to fall back on, so a blank name is a 400 and never
        clears it. An empty color resets it to the default "#95a2b3". Editing
        a label changes it on every issue it is attached to. Labels in another
        org read as 404.
      parameters:
        - $ref: "#/components/parameters/Id"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                color:
                  type: string
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [label]
                properties:
                  label:
                    $ref: "#/components/schemas/Label"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
    delete:
      operationId: deleteLabel
      tags: [labels]
      summary: Delete a label
      description: >-
        Hard delete. The label disappears from every issue it was attached
        to, unconditionally — unlike a workflow state, a label is never
        load-bearing for an issue, so there is no in-use guard here and no
        count in this response. Check issueCount from GET /v1/labels first
        if that matters to the caller. The deletion is audit-logged with the
        affected count.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/cycles:
    get:
      operationId: listCycles
      tags: [cycles]
      summary: List cycles
      description: >-
        Cycles across the org's teams, highest number first. Filter with
        team_id.


        No create here. Cycles are roller-owned: a team gets them by turning on
        `cyclesEnabled` in PATCH /v1/teams/{id}/settings, and a background job
        creates, numbers and dates every one from that team's cadence. A create
        route used to exist and was removed — a hand-made cycle could straddle
        a boundary the scheduler was about to place there itself.
      parameters:
        - $ref: "#/components/parameters/TeamIdFilter"
      responses:
        "200":
          description: Cycles
          content:
            application/json:
              schema:
                type: object
                required: [cycles]
                properties:
                  cycles:
                    type: array
                    items:
                      $ref: "#/components/schemas/Cycle"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"

  /v1/cycles/{id}:
    patch:
      operationId: updateCycle
      tags: [cycles]
      summary: Rename a cycle
      description: >-
        The one field of a cycle still changeable by hand. number and dates are
        roller-owned and no longer accepted here — send them and they are
        silently ignored, the same as any unknown field on any PATCH body in
        this service. There is no delete: a cycle cannot be removed once the
        roller has made it. A name another cycle in the same team already uses
        is a 409, compared trimmed and case-insensitively; "" clears the name
        and never collides with another blank one. A cycle's team is immutable,
        so teamId is not accepted. Cycles in another org read as 404.
      parameters:
        - $ref: "#/components/parameters/Id"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [cycle]
                properties:
                  cycle:
                    $ref: "#/components/schemas/Cycle"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /v1/imports:
    get:
      operationId: listImports
      tags: [imports]
      summary: List imports, newest first
      description: >-
        Administrators only (`org.settings.manage`): a job carries the names and
        emails the uploaded file contained. Imports in another workspace are
        never listed. `team_id` narrows to one team (404 if the team is not one
        the caller can reach). `batch_id` narrows to the imports of one batch
        (400 if it is not an id). `limit` defaults to 20, maximum 50, which is
        also the most a batch can hold.
      parameters:
        - $ref: "#/components/parameters/TeamIdFilter"
        - name: batch_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
      responses:
        "200":
          description: The caller's imports
          content:
            application/json:
              schema:
                type: object
                required: [imports]
                properties:
                  imports:
                    type: array
                    items:
                      $ref: "#/components/schemas/Import"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "404":
          $ref: "#/components/responses/ImportNotFound"
    post:
      operationId: createImport
      tags: [imports]
      summary: Upload a file to import
      description: >-
        Step 1 of 3 (upload, preview, commit). Administrators only
        (`org.settings.manage`). multipart/form-data with the CSV in `file`
        (at most 10 MB and 5,000 rows) and the target `teamId`. The file is
        read at once, so one that cannot be used (empty, not a CSV, a stray
        quote, too many rows) is refused with 422 and nothing is stored.
        UTF-8, UTF-16 and Windows-1252 are recognised, and the separator is
        detected.


        Nothing is written to the team. The response carries the file's
        headers, a few sample rows, and `suggestedMapping`, a starting point
        guessed from the header names for the caller to review. `namespace`
        names where the file's row ids come from: rows already imported under
        the same namespace are recognised on a re-run and skipped. It defaults
        to one derived from the file name, or to `linear` or `jira` for an export
        of that tool, so overlapping exports share it. `detected` is set when the file is
        recognised as another tool's export. `previousImport` is set when this
        exact file was imported into the team before.


        Uploads that are never committed are closed after 24 hours and their
        file deleted. A team holds at most 10 unfinished imports (409).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, teamId]
              properties:
                file:
                  type: string
                  format: binary
                teamId:
                  type: string
                  format: uuid
                source:
                  type: string
                  enum: [csv]
                  default: csv
                  description: Only `csv` is supported so far.
                namespace:
                  type: string
                  maxLength: 200
      responses:
        "201":
          description: Uploaded and read
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ImportUpload"
        "400":
          $ref: "#/components/responses/ImportBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "404":
          $ref: "#/components/responses/ImportNotFound"
        "409":
          $ref: "#/components/responses/ImportConflict"
        "413":
          $ref: "#/components/responses/ImportTooLarge"
        "422":
          $ref: "#/components/responses/ImportUnprocessable"

  /v1/imports/inspect:
    post:
      operationId: inspectImport
      tags: [imports]
      summary: Say which teams a file names
      description: >-
        Before an upload, for a file that holds several teams (a Linear export of
        a whole workspace). Administrators only (`org.settings.manage`).
        multipart/form-data with the CSV in `file` and, optionally, `column`,
        the header of the column that names the team. Without `column` it is the
        `Team` column, and `teamColumn` is null when the file has none (a Jira
        file has none, but its Project column can be named).


        Nothing is stored. Each name in the file comes back with how many rows it
        has and `match`, the team of the workspace it most likely means: by team
        name, then by team key, ignoring case and spacing. `match` is null when
        nothing fits or when two teams fit, because Accounts allows two teams to
        share a name and a guess would put issues in a team nobody chose. Only
        teams the caller can reach are matched. Rows with no team are counted in
        `noTeamRows`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                column:
                  type: string
                  description: The header of the column that names the team. Defaults to `Team`.
      responses:
        "200":
          description: What the file names
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ImportInspection"
        "400":
          $ref: "#/components/responses/ImportBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "413":
          $ref: "#/components/responses/ImportTooLarge"
        "422":
          $ref: "#/components/responses/ImportUnprocessable"

  /v1/imports/batches:
    post:
      operationId: createImportBatch
      tags: [imports]
      summary: Upload a file that names several teams, one import per team
      description: >-
        Step 1 of 3 for a file that holds several teams. Administrators only
        (`org.settings.manage`). multipart/form-data with the CSV in `file`, the
        `teamMap`, and optionally `column`, `source` and `namespace` as on
        `createImport`.


        The file is cut by team and each team's rows become an ordinary import in
        that team, so what an import promises (one team, a preview before it
        runs, a ledger that makes a re-run harmless, an undo) is unchanged. The
        imports of one upload share a `batchId`, and each is then previewed and
        committed with the routes of a single import. A part is a file of its
        own, so its rows are numbered within it, and a part that is the same as
        one imported before is recognised by `previousImport`.


        `teamMap` is a JSON object with one entry for every team the file names:
        the name as written in the file (case and spacing do not matter), and the
        id of the team its rows go to, or null to leave them out. A name the map
        does not mention is refused (`team_not_mapped`), because dropping rows by
        leaving them out of the map is how work is lost unnoticed. Several names
        may go to one team, and their rows are then one import, in file order.
        Teams are never created: a team the file names that the workspace does
        not have is left out until it exists in Accounts. A team the caller
        cannot reach is a 404.


        Everything is stored or nothing is. At most 50 teams, and a team holds at
        most 10 unfinished imports (409 with the `teamId`). A file that cannot be
        cut without changing it is refused with `not_splittable`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, teamMap]
              properties:
                file:
                  type: string
                  format: binary
                teamMap:
                  type: string
                  description: >-
                    A JSON object from each team name in the file to the id of
                    the team its rows go to, or null to leave them out.
                  example: '{"Engineering": "5f0c0e0a-1c1b-4c55-9a0e-0d9f3c2a7b11", "Operations": null}'
                column:
                  type: string
                  description: The header of the column that names the team. Defaults to `Team`.
                source:
                  type: string
                  enum: [csv]
                  default: csv
                namespace:
                  type: string
                  maxLength: 200
      responses:
        "201":
          description: Uploaded, cut and read
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ImportBatch"
        "400":
          $ref: "#/components/responses/ImportBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "404":
          $ref: "#/components/responses/ImportNotFound"
        "409":
          $ref: "#/components/responses/ImportConflict"
        "413":
          $ref: "#/components/responses/ImportTooLarge"
        "422":
          $ref: "#/components/responses/ImportUnprocessable"

  /v1/imports/linear/inspect:
    post:
      operationId: inspectLinear
      tags: [imports]
      summary: Say what a Linear API key can see
      description: >-
        Administrators only (`org.settings.manage`). Reads the Linear workspace the
        key belongs to, its teams, and its people, and stores nothing. Each Linear
        team comes with `match`, the team of this workspace it looks like by name
        and then by key (null when none, or when two would fit, because guessing
        between them would put issues in a team nobody chose). `people` says how
        many active people Linear has and which of them are not members of this
        workspace yet, matched by email. Their issues are imported unassigned and
        handed over when they join, so this only tells the admin who will wait.


        The key is a Linear personal API key. It is held in memory for the call and
        never stored, logged, audited or returned, and an error never carries it.
        A key that is not a key (a space, a line break, too long) is a 400
        `bad_key`. One Linear does not accept is a 422 `linear_key_rejected`. A
        Linear rate limit is a 429 `linear_rate_limited`, and Linear failing is a
        502 `linear_unavailable`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LinearKeyRequest"
      responses:
        "200":
          description: What the key can see
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LinearInspection"
        "400":
          $ref: "#/components/responses/ImportBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "422":
          $ref: "#/components/responses/ImportUnprocessable"
        "429":
          $ref: "#/components/responses/LinearRateLimited"
        "502":
          $ref: "#/components/responses/LinearUnavailable"

  /v1/imports/linear/fetches:
    get:
      operationId: listLinearFetches
      tags: [imports]
      summary: List the latest Linear fetches of the workspace
      description: >-
        Administrators only. The ten most recent, newest first, so a screen that was
        closed can find a fetch that is still running.
      responses:
        "200":
          description: The fetches
          content:
            application/json:
              schema:
                type: object
                required: [fetches]
                properties:
                  fetches:
                    type: array
                    items:
                      $ref: "#/components/schemas/LinearFetch"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
    post:
      operationId: startLinearFetch
      tags: [imports]
      summary: Start reading Linear teams into import jobs
      description: >-
        Step 1 of 3 for a Linear import (fetch, preview, start). Administrators
        only. Names the Linear teams to read, each with the team of this workspace
        its issues go to, and answers 202 at once. The key, the Linear teams and the
        Easerix teams are all checked first, so a mistake is answered now and not
        found in a progress bar. Nothing is created in a team: a fetch only makes
        import jobs.


        The fetch runs in the background, one Linear team after another, and each
        team that is read becomes an ordinary import job in the team chosen: status
        `uploaded`, source `linear`, the same batch as the others, and the column
        mapping already saved (the suggested one, with the comment layout that keeps
        an author and a date). From there it is an upload: `previewImport` with the
        saved mapping, `commitImport`, `undoImport`, and the same reconciliation.
        Issues carry Linear's own identifier (ENG-123) under the source name
        `linear`, the name a Linear CSV export is given too, so reading a team twice,
        or after a CSV export of it, never imports an issue twice.


        The key is held in memory by the fetch and dropped when it ends. It is not
        stored, logged, audited or returned. A fetch cut short by a restart cannot
        be resumed: it is found by its heartbeat and marked failed, the admin pastes
        the key again, and the teams it had finished stay as jobs.


        Refusals: 400 `bad_key`, `bad_request` (no teams, more than 50, a team twice)
        or `unknown_linear_team`; 404 for a team of this workspace the caller cannot
        reach; 409 `fetch_in_progress` (one fetch runs per workspace) or
        `too_many_unfinished`; 422 `linear_key_rejected`; 429 and 502 as for
        `inspectLinear`. A team with more than 5,000 issues, the most one import
        takes, is not refused up front: it fails on its own, with the reason, and the
        others carry on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LinearFetchRequest"
      responses:
        "202":
          description: Started
          content:
            application/json:
              schema:
                type: object
                required: [fetch]
                properties:
                  fetch:
                    $ref: "#/components/schemas/LinearFetch"
        "400":
          $ref: "#/components/responses/ImportBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "404":
          $ref: "#/components/responses/ImportNotFound"
        "409":
          $ref: "#/components/responses/ImportConflict"
        "422":
          $ref: "#/components/responses/ImportUnprocessable"
        "429":
          $ref: "#/components/responses/LinearRateLimited"
        "502":
          $ref: "#/components/responses/LinearUnavailable"

  /v1/imports/linear/fetches/{id}:
    get:
      operationId: getLinearFetch
      tags: [imports]
      summary: Get how a Linear fetch is going
      description: >-
        Administrators only. Poll this while a fetch runs: each team reports its
        state and how many issues and comments are read, and a team that is done
        names the import job it became (`importId`). A fetch that is over cannot
        have a team still being read, so such a team is shown as `failed` with code
        `not_fetched`. A fetch in another workspace is a 404.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: The fetch
          content:
            application/json:
              schema:
                type: object
                required: [fetch]
                properties:
                  fetch:
                    $ref: "#/components/schemas/LinearFetch"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "404":
          $ref: "#/components/responses/ImportNotFound"

  /v1/imports/linear/fetches/{id}/cancel:
    post:
      operationId: cancelLinearFetch
      tags: [imports]
      summary: Stop a Linear fetch
      description: >-
        Administrators only. Asks the fetch to stop after the page it is on. The
        teams it had finished stay as import jobs. One that is over is a 409
        `already_finished`. The action is recorded on the security log.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Asked to stop
          content:
            application/json:
              schema:
                type: object
                required: [fetch]
                properties:
                  fetch:
                    $ref: "#/components/schemas/LinearFetch"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "404":
          $ref: "#/components/responses/ImportNotFound"
        "409":
          $ref: "#/components/responses/ImportConflict"

  /v1/imports/{id}:
    get:
      operationId: getImport
      tags: [imports]
      summary: Get an import's status and its row errors
      description: >-
        Administrators only. Poll this while an import runs: the counts move as
        rows are written. `errors` holds the first 100 rows that could not be
        written (in row order) and `errorCount` the true total. An import in
        another workspace is a 404.


        `errors` also holds the rows the plan left out, which the worker saves
        when it starts (the upload is deleted when the job ends, so this is the
        only record of which rows and why). Notes, such as a value that was
        dropped from an issue that was still imported, are not rows left out and
        are not kept. At most 500 are saved, and when the plan had more, a last
        entry with code `more_problems` says how many are not listed.
        `errors=all` returns up to 10,000 instead of 100, for downloading them.


        `pendingPeople` lists the people the file named as assignee who were not
        members of the workspace, most issues first, with how many of this
        import's issues are still waiting for each. Those issues were created
        unassigned and are handed to the person when they are added to the
        workspace, so a person who has since joined is no longer listed. It is
        empty until the import has ended, and at most 50 are listed.
        `pendingPeopleTotal` and `pendingIssuesTotal` count everyone and every
        issue still waiting, however many are listed.
      parameters:
        - $ref: "#/components/parameters/Id"
        - name: errors
          in: query
          required: false
          description: "`all` returns every saved row problem (up to 10,000) instead of the first 100."
          schema:
            type: string
            enum: [all]
      responses:
        "200":
          description: The import
          content:
            application/json:
              schema:
                type: object
                required: [import, errors, errorCount, pendingPeople, pendingPeopleTotal, pendingIssuesTotal]
                properties:
                  import:
                    $ref: "#/components/schemas/Import"
                  errors:
                    type: array
                    items:
                      $ref: "#/components/schemas/ImportRowError"
                  errorCount:
                    type: integer
                  pendingPeople:
                    type: array
                    items:
                      $ref: "#/components/schemas/ImportWaitingPerson"
                  pendingPeopleTotal:
                    type: integer
                    description: How many people the import is still waiting for, listed or not.
                  pendingIssuesTotal:
                    type: integer
                    description: How many issues are waiting for them.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "404":
          $ref: "#/components/responses/ImportNotFound"

  /v1/imports/{id}/upload:
    get:
      operationId: getImportUpload
      tags: [imports]
      summary: Get what an unstarted import's file looked like
      description: >-
        Administrators only. The same answer `createImport` gave when the file was
        uploaded, for an import that has not started (`uploaded` or `planned`): the
        file's headers and first rows, `detected`, and `suggestedMapping`, which is
        the mapping the import already has when it has one (made by the Linear
        connector, or saved by a preview) and the suggestion otherwise. It lets a
        screen review an import it did not upload itself, such as each team of a
        Linear fetch, or one found again after a reload. An import that has started
        is a 409 `not_editable`. One in another workspace is a 404.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: The upload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ImportUpload"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "404":
          $ref: "#/components/responses/ImportNotFound"
        "409":
          $ref: "#/components/responses/ImportConflict"

  /v1/imports/{id}/preview:
    post:
      operationId: previewImport
      tags: [imports]
      summary: Say exactly what an import would do
      description: >-
        Step 2 of 3. Administrators only. Takes the column mapping and returns
        the plan: how many rows would be created, skipped (already imported) or
        refused, which statuses, labels and projects would be created, which
        people in the file are not members, the problems found, and the first
        few rows as they would be written. Nothing is written to the team.


        Can be repeated, and each call replaces the last. `plan.fingerprint`
        identifies the plan: commit must carry the fingerprint of the most
        recent preview. A mapping that cannot be used is a 422 listing every
        problem together. Only an import that has not been started can be
        previewed (409 otherwise).
      parameters:
        - $ref: "#/components/parameters/Id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mapping]
              properties:
                mapping:
                  $ref: "#/components/schemas/ImportMapping"
                namespace:
                  type: string
                  maxLength: 200
                  description: Rename the source. Applies from this preview on.
      responses:
        "200":
          description: The plan
          content:
            application/json:
              schema:
                type: object
                required: [import, plan]
                properties:
                  import:
                    $ref: "#/components/schemas/Import"
                  plan:
                    $ref: "#/components/schemas/ImportPlan"
        "400":
          $ref: "#/components/responses/ImportBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "404":
          $ref: "#/components/responses/ImportNotFound"
        "409":
          $ref: "#/components/responses/ImportConflict"
        "422":
          $ref: "#/components/responses/ImportUnprocessable"

  /v1/imports/{id}/commit:
    post:
      operationId: commitImport
      tags: [imports]
      summary: Start an import
      description: >-
        Step 3 of 3. Administrators only. Queues the import, which a background
        worker then carries out one row after another (poll `getImport`). The
        body names the preview that was approved by its `fingerprint`. The plan
        is built again at this moment, and if anything it depends on has
        changed (a status deleted, a person added, another import finished) the
        answer is 409 `plan_changed` with the new plan, and nothing runs. The
        new plan is kept as the latest preview, so after reading it the caller
        commits with its fingerprint, without previewing again.


        Refusals: 409 `preview_first` (not previewed, or already started),
        `stale_preview` (a newer preview exists), `import_in_progress` (one
        import runs per team at a time), 422 `nothing_to_import`. The person
        who commits is the creator of any issue whose row names no creator.
        The action is recorded on the security log.
      parameters:
        - $ref: "#/components/parameters/Id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fingerprint]
              properties:
                fingerprint:
                  type: string
      responses:
        "202":
          description: Queued
          content:
            application/json:
              schema:
                type: object
                required: [import]
                properties:
                  import:
                    $ref: "#/components/schemas/Import"
        "400":
          $ref: "#/components/responses/ImportBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "404":
          $ref: "#/components/responses/ImportNotFound"
        "409":
          $ref: "#/components/responses/ImportConflict"
        "422":
          $ref: "#/components/responses/ImportUnprocessable"

  /v1/imports/{id}/cancel:
    post:
      operationId: cancelImport
      tags: [imports]
      summary: Cancel an import
      description: >-
        Administrators only. An import that has not started is closed at once
        and its file deleted. One that is running is asked to stop
        (`cancelRequested`) and does so within a few rows, leaving what it has
        already written: a later import of the same file finishes the rest
        without duplicating anything. One that has finished is a 409
        `already_finished`. The action is recorded on the security log.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Canceled, or asked to stop
          content:
            application/json:
              schema:
                type: object
                required: [import]
                properties:
                  import:
                    $ref: "#/components/schemas/Import"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "404":
          $ref: "#/components/responses/ImportNotFound"
        "409":
          $ref: "#/components/responses/ImportConflict"

  /v1/imports/{id}/undo:
    post:
      operationId: undoImport
      tags: [imports]
      summary: Undo an import
      description: >-
        Administrators only. Takes away what the import made, and nothing that
        people have done since. An issue is erased for good (not sent to
        Recently Deleted) only if nobody has touched it: it was not edited after
        the import finished, has no comment from anyone but the import, no live
        attachment and no link added since. Such an issue stays, with the
        comments the import gave it, and is counted in `keptIssues`. Comments the
        import added to issues of an earlier import are removed unless someone
        edited them. The ledger rows of erased issues go with them, so the same
        file can be imported again, with a corrected mapping, as if the first
        run had not happened. Statuses, labels and projects the import created
        are kept. Allowed for 7 days after the import finishes (`undoableUntil`).
        A 409 says why not: `not_undoable` (not finished), `undo_expired`, or
        `already_undone` (also what the loser of two simultaneous undos gets).
        Recorded on the security log.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Undone
          content:
            application/json:
              schema:
                type: object
                required: [import, result]
                properties:
                  import:
                    $ref: "#/components/schemas/Import"
                  result:
                    type: object
                    required: [removedIssues, keptIssues, removedComments, keptComments, removedCycles, keptCycles]
                    properties:
                      removedIssues:
                        type: integer
                      keptIssues:
                        type: integer
                        description: Issues someone has touched since, which stay.
                      removedComments:
                        type: integer
                        description: >-
                          Comments of this import that are gone, with their
                          issues or on their own.
                      keptComments:
                        type: integer
                        description: Comments of this import that stay, on kept issues or because they were edited.
                      removedCycles:
                        type: integer
                        description: Cycles the import made that are gone.
                      keptCycles:
                        type: integer
                        description: >-
                          Cycles the import made that stay, because an issue is
                          in them or someone renamed them.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ImportForbidden"
        "404":
          $ref: "#/components/responses/ImportNotFound"
        "409":
          $ref: "#/components/responses/ImportConflict"

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
    docsBasic:
      type: http
      scheme: basic
      description: >-
        Gates /docs and /docs/openapi.yaml only. Credentials come from the
        DOCS_USER/DOCS_PASSWORD env vars; if either is unset the routes are
        not registered at all (404), never served open.

  parameters:
    Id:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    TeamIdFilter:
      name: team_id
      in: query
      description: Restrict to one team.
      schema:
        type: string
        format: uuid

  responses:
    Message:
      description: Confirmation
      content:
        application/json:
          schema:
            type: object
            required: [message]
            properties:
              message:
                type: string
    BadRequest:
      description: Invalid request body (the error message names the offending field)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: >-
        Missing/invalid bearer token, or a token without org context
        ("token missing org context — refresh your session")
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    ToolDisabled:
      description: >-
        The tasks tool is turned off for the caller's organization
        ("this tool is turned off for your organization")
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    PlatformUnreachable:
      description: >-
        `platform_unreachable` — the auth service, which owns the team entity
        in a team workspace, could not be reached. Tasks does NOT fall back to
        its last-synced copy: serving a stale team list would mean serving a
        stale MEMBERSHIP list, and a team the caller has since been removed
        from would stay visible. Retryable; the caller should be offered a
        retry rather than an empty state, which would read as "you have no
        teams".
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: >-
        Refused for one of two reasons, told apart by `code`.

        `tool_disabled` (no `code` field) — the tasks tool is turned off for the
        caller's workspace. Nobody in the workspace can reach this route,
        whatever their role; an owner or admin fixes it in Accounts.

        `permission_denied` — the tool is on, but the caller may not do this.
        `required` names the workspace permission that would allow it.


        For team-administered routes (workflow states, labels, cycles, project
        deletion and restore, permanent issue erasure) there are TWO ways to
        hold it: the workspace permission in `required`, or leading the team
        the row belongs to. A team's own lead administers their board without
        being a workspace admin, so `required` names the workspace route to
        the same result rather than the only one.


        Access tokens live one hour, so a caller whose role or team leadership
        changed mid-session sees this until their token rolls: treat the first
        `permission_denied` as a cue to refresh the session and re-render,
        rather than as a permanent answer.

        Never used for tenancy. A row in another workspace is a 404, so that a
        refusal can never confirm the row exists.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: >-
        Not found — including rows that exist but belong to another org
        (org tenancy reads as 404, never 403)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    TooManyRequests:
      description: >-
        Rate limit exceeded (EAS-77). Retry-After gives the number of seconds
        to wait before retrying.
      headers:
        Retry-After:
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: >-
        The change collides with an existing row (the error message names the
        collision)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    StorageUnconfigured:
      description: Attachment storage (R2) is not configured for this deployment
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    ImportBadRequest:
      description: >-
        Invalid request. `code` is `bad_request` (a field is missing or the body
        is not what the route takes), `invalid_mapping` (the mapping has an
        unknown key or an unsupported version), or `unsupported_source`. For a
        file of several teams it can also be `no_team_column`, `team_not_mapped`
        (the body then carries `teams` and `teamsTotal`), `unknown_team`,
        `nothing_mapped` or `too_many_teams`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ImportErrorBody"
    ImportForbidden:
      description: >-
        Refused. `permission_denied` (the caller is not an owner or admin;
        `required` is `org.settings.manage`), `no_member` (the caller has no
        member profile yet: open Tasks once), or the tasks tool is turned off
        for the workspace (no `code`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ImportErrorBody"
    ImportNotFound:
      description: >-
        Not found, including an import or team in another workspace. Workspace
        tenancy reads as 404, never 403.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ImportErrorBody"
    LinearRateLimited:
      description: >-
        Linear's rate limit is used up, and resets too late to wait for
        (`linear_rate_limited`). The message says when to try again.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ImportErrorBody"
    LinearUnavailable:
      description: Linear could not be reached, or answered with something unusable (`linear_unavailable`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ImportErrorBody"
    ImportConflict:
      description: >-
        The request collides with the import's state. `code` is one of
        `not_editable`, `preview_first`, `stale_preview`, `plan_changed` (the
        body then carries the new `plan`), `import_in_progress`,
        `too_many_unfinished`, `already_finished`, or `unusable`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ImportErrorBody"
    ImportTooLarge:
      description: The file is over the 10 MB limit (`file_too_large`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ImportErrorBody"
    ImportUnprocessable:
      description: >-
        The file or mapping cannot be used. For a file `code` is `empty_file`,
        `not_csv`, `no_rows`, `one_column`, `bad_csv` (with the `line`),
        `too_many_rows`, `too_many_columns`, `cell_too_large` or
        `bad_encoding`; for a mapping it is `invalid_mapping` with every
        problem in `problems`; at commit it can be `nothing_to_import`; for a
        file of several teams it can be `not_splittable` (the file cannot be cut
        by team without changing it).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ImportErrorBody"

  schemas:
    Member:
      type: object
      required: [id, userId, name, email, created]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string
        created:
          type: string
          format: date-time

    ActorRef:
      type: object
      description: Compact member reference embedded in comments, activities, and notifications.
      required: [id, name, email]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string

    TeamSettings:
      type: object
      required:
        [
          teamId,
          key,
          defaultPriority,
          defaultAssignee,
          estimateScale,
          retentionDays,
          cyclesEnabled,
          cycleWeeks,
          cycleStartDay,
          cycleUpcoming,
          cycleCooldownDays,
          cycleRollover,
        ]
      properties:
        teamId:
          type: string
          format: uuid
        key:
          type: string
          description: Issue prefix — the "GEN" in GEN-42.
        defaultStateId:
          type: string
          format: uuid
          nullable: true
          description: Null means new issues land in the team's lowest-position state.
        defaultPriority:
          type: integer
          minimum: 0
          maximum: 4
        defaultAssignee:
          type: string
          enum: [none, creator]
        estimateScale:
          type: string
          enum: [none, linear, fibonacci, exponential, tshirt]
          description: "\"none\" leaves estimates unvalidated."
        retentionDays:
          type: integer
          minimum: 1
          maximum: 365
        cyclesEnabled:
          type: boolean
        cycleWeeks:
          type: integer
          minimum: 1
          maximum: 8
        cycleStartDay:
          type: integer
          minimum: 1
          maximum: 7
        cycleUpcoming:
          type: integer
          minimum: 0
          maximum: 6
        cycleCooldownDays:
          type: integer
          minimum: 0
          maximum: 6
        cycleRollover:
          type: boolean

    Team:
      type: object
      required: [id, key, name, created]
      properties:
        id:
          type: string
          format: uuid
        key:
          type: string
          description: Short issue-key prefix (e.g. "GEN").
        name:
          type: string
        created:
          type: string
          format: date-time

    State:
      type: object
      required: [id, teamId, name, type, color, position, description]
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        name:
          type: string
        type:
          type: string
          enum: [backlog, unstarted, started, completed, canceled]
        color:
          type: string
        position:
          type: integer
        description:
          type: string
          description: Free text, "" when unset. Independent of name/color/type.
        canDelete:
          type: boolean
          description: >-
            Whether DELETE (or a type-changing PATCH) would currently be
            allowed on this state, i.e. it is not its team's last state of
            its own type. Present on listStates, createState and updateState's
            own row; absent when a state is embedded on an issue, since that
            surface has no delete-this-column action and computing it per row
            on every issue in a list is not worth the query.

    Project:
      type: object
      required: [id, teamId, name, description, status, leadId, targetDate, created, modified]
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
        status:
          type: string
          enum: [planned, in_progress, paused, completed, canceled]
        leadId:
          type: string
          format: uuid
          nullable: true
        targetDate:
          type: string
          format: date
          nullable: true
        created:
          type: string
          format: date-time
        modified:
          type: string
          format: date-time

    Label:
      type: object
      required: [id, teamId, name, color, issueCount]
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        name:
          type: string
        color:
          type: string
        issueCount:
          type: integer
          description: >-
            Non-deleted issues currently carrying this label. Shown so a
            caller can judge the blast radius of a delete before making one —
            deleting a label is not blocked at any count, it just removes the
            label from every issue this number counts.

    LabelRef:
      type: object
      required: [id, teamId, name, color]
      description: A label as embedded on an issue. No issueCount — that is a label-management fact, not an issue one; see Label.
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        name:
          type: string
        color:
          type: string

    Cycle:
      type: object
      required: [id, teamId, number, name, startsAt, endsAt]
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        number:
          type: integer
        name:
          type: string
        startsAt:
          type: string
          format: date
          nullable: true
        endsAt:
          type: string
          format: date
          nullable: true

    IssueMatch:
      type: object
      description: >-
        Why a row appeared in a scope=all search. The best place wins, in this
        order: title, description, the newest matching comment, then the issue
        key.
      required: [field, snippet]
      properties:
        field:
          type: string
          enum: [title, description, comment, key]
        snippet:
          type: array
          description: >-
            The matching text as consecutive parts to concatenate. Hit parts are
            the words found. Long text is windowed around the first hit and cut
            ends are marked with an ellipsis. Plain text only: the server never
            sends markup, so render each part as text and wrap the hits.
          items:
            type: object
            required: [text, hit]
            properties:
              text:
                type: string
              hit:
                type: boolean
        commentId:
          type: string
          format: uuid
          description: Present when field is comment. The comment that matched.
        author:
          type: string
          description: >-
            Present when field is comment and the author is still a member.
            Absent, not empty, otherwise.
        at:
          type: string
          format: date-time
          description: Present when field is comment. When it was written.

    Issue:
      type: object
      required:
        [
          id,
          teamId,
          projectId,
          number,
          title,
          stateId,
          state,
          priority,
          assigneeId,
          assignee,
          creatorId,
          parentId,
          cycleId,
          estimate,
          position,
          dueDate,
          labels,
          created,
          modified,
          completedAt,
        ]
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        projectId:
          type: string
          format: uuid
          nullable: true
        number:
          type: integer
          description: Per-team incrementing issue number (team key + number is the display id).
        title:
          type: string
        description:
          type: string
        stateId:
          type: string
          format: uuid
        state:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/State"
        priority:
          type: integer
          description: 0 none, 1 urgent, 2 high, 3 medium, 4 low.
        assigneeId:
          type: string
          format: uuid
          nullable: true
        assignee:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Member"
        creatorId:
          type: string
          format: uuid
        parentId:
          type: string
          format: uuid
          nullable: true
        cycleId:
          type: string
          format: uuid
          nullable: true
        estimate:
          type: integer
          nullable: true
          description: Null means "no estimate" (0 is normalized to null).
        position:
          type: number
        dueDate:
          type: string
          format: date
          nullable: true
        labels:
          type: array
          items:
            $ref: "#/components/schemas/LabelRef"
        childCount:
          type: integer
          description: >-
            Number of sub-issues. Present on listIssues rows only, counted in one
            grouped query per page rather than one per row. Absent from a single
            issue read.
        blockedByCount:
          type: integer
          description: >-
            Open blockers pointing at this issue. Present on listIssues rows
            only, counted in one grouped query per page. A completed, canceled,
            or soft-deleted blocker is not included. Absent from a single issue
            read; that page loads relations itself.
        match:
          allOf:
            - $ref: "#/components/schemas/IssueMatch"
          description: >-
            Present on listIssues rows when scope=all and q is set: where the
            search matched. Absent otherwise.
        created:
          type: string
          format: date-time
        modified:
          type: string
          format: date-time
        completedAt:
          type: string
          format: date-time
          nullable: true
          description: Set when the issue moves to a completed-type state; cleared otherwise.
        mentions:
          type: array
          description: >-
            Members @mentioned in this issue's DESCRIPTION, each resolved to
            their CURRENT name/email rather than what was typed, exactly as a
            comment's own `mentions` works. Present on single-issue reads and
            writes only, never on listIssues rows — same reasoning as
            `following` below. Empty, never omitted, when the description
            mentions no one.
          items:
            $ref: "#/components/schemas/ActorRef"
        following:
          type: boolean
          description: >-
            Whether the CALLING member currently follows this issue.
            Present on single-issue reads and writes only (create, get,
            update, restore, label add/remove) — like childCount above,
            never on listIssues rows, since computing it per row would add a
            followers query per page. Toggle it with PUT/DELETE
            /v1/issues/{id}/followers/me.
        importedFrom:
          type: object
          nullable: true
          description: >-
            Where an import made this issue from, and the key it had there, so
            an issue moved from Linear can still be found as ENG-123. Null for
            an issue no import created. Present on single-issue reads and
            writes only, like `following`. The same key is matched by `q` in
            both scopes, and a scope=all match on it is reported as field `key`.
          required: [namespace, key]
          properties:
            namespace:
              type: string
              description: >-
                The source name the import was run under (linear and jira by
                default, a name derived from the file for a CSV).
            key:
              type: string
              description: The issue's id in that source, for example ENG-123.

    IssueRelation:
      type: object
      required: [id, type, issueId, relatedIssueId]
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [blocks, related, duplicate]
          description: Stored type. blocked_by is not stored; it is blocks with the ids swapped.
        issueId:
          type: string
          format: uuid
          description: The source. For blocks, the issue that is holding the other up.
        relatedIssueId:
          type: string
          format: uuid
          description: The target. For blocks, the issue that is waiting.

    IssueRelationEdge:
      type: object
      required: [id, type, issue]
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [blocks, related, duplicate]
        direction:
          type: string
          enum: [blocks, blocked_by]
          description: Set on resolvedBlocks only. Which way the block used to run.
        issue:
          type: object
          required: [id, teamId, number, title, stateId, state]
          description: The other issue, not the one the list was loaded for.
          properties:
            id:
              type: string
              format: uuid
            teamId:
              type: string
              format: uuid
            number:
              type: integer
            title:
              type: string
            stateId:
              type: string
              format: uuid
            state:
              nullable: true
              allOf:
                - $ref: "#/components/schemas/State"

    IssueRelations:
      type: object
      required:
        [
          blockedBy,
          blockedByTotal,
          blocks,
          blocksTotal,
          related,
          relatedTotal,
          resolvedBlocks,
          resolvedBlocksTotal,
          duplicateOf,
          duplicates,
          duplicatesTotal,
          truncated,
        ]
      properties:
        blockedBy:
          type: array
          items:
            $ref: "#/components/schemas/IssueRelationEdge"
        blockedByTotal:
          type: integer
        blocks:
          type: array
          items:
            $ref: "#/components/schemas/IssueRelationEdge"
        blocksTotal:
          type: integer
        related:
          type: array
          items:
            $ref: "#/components/schemas/IssueRelationEdge"
        relatedTotal:
          type: integer
        resolvedBlocks:
          type: array
          description: Blocks whose source is completed or canceled. The stored type is still blocks.
          items:
            $ref: "#/components/schemas/IssueRelationEdge"
        resolvedBlocksTotal:
          type: integer
        duplicateOf:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/IssueRelationEdge"
        duplicates:
          type: array
          items:
            $ref: "#/components/schemas/IssueRelationEdge"
        duplicatesTotal:
          type: integer
        truncated:
          type: boolean
          description: True when this issue has more links than the response loaded.

    Comment:
      type: object
      required: [id, issueId, authorId, body, created, modified, author, mentions, can]
      properties:
        id:
          type: string
          format: uuid
        issueId:
          type: string
          format: uuid
        authorId:
          type: string
          format: uuid
        body:
          type: string
        created:
          type: string
          format: date-time
        modified:
          type: string
          format: date-time
          nullable: true
          description: Null until the comment is edited — non-null means "(edited)".
        author:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ActorRef"
        mentions:
          type: array
          description: >-
            Members @mentioned in this comment (EAS-92), each resolved to
            their CURRENT name/email rather than what was typed — a rename
            after the fact is reflected here, never a stale snapshot. Empty,
            never omitted, when the comment mentions no one.
          items:
            $ref: "#/components/schemas/ActorRef"
        can:
          type: object
          description: >-
            What the CALLING user may do to this comment — both true only for
            its author, mirroring the checks on PATCH and DELETE
            /v1/comments/{id}.

            Present because the answer varies per row: everything else Tasks
            gates is settled for the whole session by the caller's role, but
            editing a comment depends on who wrote this particular one, which
            only the server knows. Clients render controls from this rather than
            comparing `authorId` themselves, so the rule lives in one place.

            Advisory. The server re-checks on every write; this only spares the
            UI from offering a control that would be refused.
          required: [update, delete]
          properties:
            update:
              type: boolean
            delete:
              type: boolean

    Attachment:
      type: object
      required: [id, url, filename, contentType, size, created, rendersInline]
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
          description: >-
            Absolute URL of GET /v1/attachments/{id}/content, the session-checked route that
            serves the bytes (send the bearer). Since EAS-267 part C; markdown written before
            then names the older form .../v1/attachments/{id}, which no longer serves anything:
            clients map both forms to /content (or, in the web app, to its own session route).
        filename:
          type: string
        contentType:
          type: string
          description: >-
            Identified from the file's own bytes at upload, not from the header the
            uploader sent. Files stored before that check existed keep whatever was
            claimed at the time; rendersInline is false for those.
        rendersInline:
          type: boolean
          description: >-
            Whether GET /v1/attachments/{id}/content will serve this file for display rather
            than as a download. True only for a small allowlist of raster image types
            whose identity was verified from the bytes; image/svg+xml is excluded
            because an SVG can carry script. Clients should use this rather than
            inspecting contentType, so the decision cannot drift from the route that
            enforces it.
        size:
          type: integer
          format: int64
        created:
          type: string
          format: date-time

    Activity:
      type: object
      required: [id, issueId, actorId, actor, type, from, to, created]
      properties:
        id:
          type: string
          format: uuid
        issueId:
          type: string
          format: uuid
        actorId:
          type: string
          format: uuid
        actor:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ActorRef"
        type:
          type: string
          enum: [created, state, priority, assignee, due_date, project, cycle, estimate]
        from:
          type: string
          description: Raw previous value (id, number, or date) — empty for "created".
        to:
          type: string
          description: Raw new value (id, number, or date) — empty for "created".
        created:
          type: string
          format: date-time

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable message. Safe to show, but prefer `code` for logic.
        code:
          type: string
          description: >-
            Stable machine-readable reason, present on refusals a client can act
            on. `permission_denied` means the caller's workspace role lacks
            `required` — worth refreshing the session before giving up, since
            tokens are up to an hour stale. Absent on errors with no actionable
            branch.
          enum: [permission_denied]
        required:
          type: string
          description: >-
            The permission the caller would need, set alongside
            `code: permission_denied`. Lets a client name who *can* perform the
            action without shipping its own copy of the role matrix.
          example: org.teams.manage

    ImportErrorBody:
      type: object
      required: [error]
      description: >-
        The refusal shape of the import routes. Unlike `Error`, `code` takes the
        many specific values an import can be refused with, listed on each
        response.
      properties:
        error:
          type: string
          description: Human-readable message, written for the person using the screen.
        code:
          type: string
          description: Stable machine-readable reason. Branch on this, not on `error`.
        required:
          type: string
          description: Set with `code` `permission_denied`. The permission the caller would need.
          example: org.settings.manage
        line:
          type: integer
          description: With `bad_csv`, the line of the file the problem is on.
        problems:
          type: array
          description: With `invalid_mapping`, every problem found, together.
          items:
            type: string
        plan:
          $ref: "#/components/schemas/ImportPlan"
        teams:
          type: array
          description: >-
            With `team_not_mapped`, the names in the file (at most 20, each cut
            to 80 characters) that `teamMap` does not say what to do with.
          items:
            type: string
        teamsTotal:
          type: integer
          description: With `team_not_mapped`, how many names there are in all.
        teamId:
          type: string
          format: uuid
          description: With `too_many_unfinished` on a batch, the team that has no room.

    Import:
      type: object
      required:
        - id
        - teamId
        - source
        - namespace
        - status
        - fileName
        - fileSize
        - totalRows
        - createdCount
        - skippedCount
        - failedCount
        - cancelRequested
        - createdBy
        - createdAt
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        source:
          type: string
          enum: [csv, jira, linear]
        namespace:
          type: string
          description: >-
            Where the file's row ids come from. Rows already imported under the
            same namespace are skipped on a re-run.
        status:
          type: string
          enum: [uploaded, planned, queued, running, completed, failed, canceled]
          description: >-
            uploaded (read, no mapping yet), planned (previewed), queued
            (committed, waiting for a worker or for a retry), running,
            completed, failed, canceled. `completed` can still have
            `failedCount` above zero: those rows were left out and listed.
        fileName:
          type: string
        fileSize:
          type: integer
          format: int64
        totalRows:
          type: integer
        createdCount:
          type: integer
          description: Issues written so far.
        skippedCount:
          type: integer
          description: Rows not written because they were already imported or had a problem.
        failedCount:
          type: integer
          description: Rows that could not be saved while running.
        cancelRequested:
          type: boolean
        createdBy:
          type: string
          format: uuid
          description: The member the run is attributed to (whoever last uploaded or committed).
        createdAt:
          type: string
          format: date-time
        startedAt:
          type: string
          format: date-time
          nullable: true
        finishedAt:
          type: string
          format: date-time
          nullable: true
        retryAfter:
          type: string
          format: date-time
          nullable: true
          description: When a queued import that hit a temporary problem will be tried again.
        undoneAt:
          type: string
          format: date-time
          nullable: true
          description: When the import was undone, if it was.
        batchId:
          type: string
          format: uuid
          nullable: true
          description: >-
            The imports one upload was split into, one per team, share this id
            (see `createImportBatch`). Null for an import uploaded on its own.
        origin:
          type: string
          enum: [file, linear_api]
          description: >-
            Where its rows came from: a file someone uploaded, or a read of Linear over
            its API (`startLinearFetch`). Only present on the list, the detail and the
            upload of a job that was not started. An import read over the API has no
            file of its own, and its columns are already matched.
        undoableUntil:
          type: string
          format: date-time
          nullable: true
          description: >-
            When the 7 day window to undo this import closes. Null when it
            cannot be undone (not finished, already undone, or too long ago).
        commentCount:
          type: integer
          description: Comments this import wrote. Only on the detail.
        mapping:
          $ref: "#/components/schemas/ImportMapping"
        reconciliation:
          type: object
          nullable: true
          description: >-
            Where every row of the file went, for showing that a migration lost
            nothing. Only on the detail, and null until the import has ended (or
            if it never started reading the file). The rows per status are
            counted from the plan when the import first starts, because the file
            is deleted when it ends, and completed with what was written. Every
            row is in exactly one of four places, so `rows` is `created` plus
            `alreadyImported` plus `refused` plus `notWritten`, in total and in
            every status. A second import of the same file after the source was
            frozen (a delta) writes only the new rows and reports the rest as
            `alreadyImported`.
          required: [rows, created, alreadyImported, refused, notWritten, allAccountedFor, byStatus]
          properties:
            rows:
              type: integer
              description: Rows in the file.
            created:
              type: integer
              description: Issues this import wrote.
            alreadyImported:
              type: integer
              description: Rows an earlier import brought in, so not written again.
            refused:
              type: integer
              description: Rows the plan left out. Each has its reason in `errors`.
            notWritten:
              type: integer
              description: >-
                Rows that were meant to be created and were not, because the row
                failed or the import was stopped. A later import of the same file
                writes them.
            allAccountedFor:
              type: boolean
              description: >-
                True when `notWritten` is zero: every row is in Tasks or listed as
                left out.
            byStatus:
              type: array
              description: The same counts for each status the file used.
              items:
                type: object
                required: [status, inFile, created, alreadyImported, refused, notWritten]
                properties:
                  status:
                    type: string
                    description: The status name, or "(no status)" for rows refused before one was read.
                  inFile:
                    type: integer
                  created:
                    type: integer
                  alreadyImported:
                    type: integer
                  refused:
                    type: integer
                  notWritten:
                    type: integer

    LinearKeyRequest:
      type: object
      required: [apiKey]
      properties:
        apiKey:
          type: string
          format: password
          writeOnly: true
          maxLength: 256
          description: >-
            A Linear personal API key, with read access. Held in memory for the
            request and never stored, logged, audited or returned.

    LinearInspection:
      type: object
      required: [workspace, teams, people]
      properties:
        workspace:
          type: object
          required: [name, urlKey]
          properties:
            name:
              type: string
            urlKey:
              type: string
        teams:
          type: array
          items:
            type: object
            required: [id, key, name, match]
            properties:
              id:
                type: string
                description: Linear's id for the team, to name it in `startLinearFetch`.
              key:
                type: string
              name:
                type: string
              match:
                type: object
                nullable: true
                description: The team of this workspace it looks like, or null.
                required: [id, name, key]
                properties:
                  id:
                    type: string
                    format: uuid
                  name:
                    type: string
                  key:
                    type: string
        people:
          type: object
          required: [total, missingTotal, missing]
          properties:
            total:
              type: integer
              description: Active people of the Linear workspace with an email address, guests left out.
            missingTotal:
              type: integer
              description: How many of them are not members of this workspace, listed or not.
            missing:
              type: array
              description: At most 50 of those people.
              items:
                type: object
                required: [name, email]
                properties:
                  name:
                    type: string
                  email:
                    type: string

    LinearFetchRequest:
      type: object
      required: [apiKey, teams]
      properties:
        apiKey:
          type: string
          format: password
          writeOnly: true
          maxLength: 256
          description: >-
            A Linear personal API key, with read access. Held in memory by the fetch
            and dropped when it ends. Never stored, logged, audited or returned.
        teams:
          type: array
          minItems: 1
          maxItems: 50
          items:
            type: object
            required: [linearTeamId, teamId]
            properties:
              linearTeamId:
                type: string
                description: Linear's id for the team (from `inspectLinear`). Each can be named once.
              teamId:
                type: string
                format: uuid
                description: The team of this workspace its issues go to. It must already exist.
        includeArchived:
          type: boolean
          default: false
          description: Read archived issues too. They are imported with their status.
        includeComments:
          type: boolean
          default: true
          description: Read the comments, with their authors and dates.
        namespace:
          type: string
          maxLength: 200
          description: >-
            The source name. Defaults to `linear`, which a Linear CSV export is given
            too, so the two never import an issue twice.

    LinearFetch:
      type: object
      required: [id, status, batchId, namespace, options, teams, error, cancelRequested, createdAt, finishedAt]
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [running, completed, failed, canceled]
          description: >-
            `completed` when at least one team became an import job, `failed` when
            none did (or the key stopped working, or the fetch was cut short), and
            `canceled` when it was stopped.
        batchId:
          type: string
          format: uuid
          description: The batch the jobs it makes share, as for a file of several teams.
        namespace:
          type: string
        options:
          type: object
          required: [includeArchived, includeComments, linearWorkspace]
          properties:
            includeArchived:
              type: boolean
            includeComments:
              type: boolean
            linearWorkspace:
              type: string
        teams:
          type: array
          items:
            $ref: "#/components/schemas/LinearFetchTeam"
        error:
          type: string
          description: Why the fetch failed. Empty otherwise. Safe to show a person.
        cancelRequested:
          type: boolean
        createdAt:
          type: string
          format: date-time
        finishedAt:
          type: string
          format: date-time
          nullable: true

    LinearFetchTeam:
      type: object
      required: [linearTeamId, key, name, teamId, state, issues, comments, notes]
      properties:
        linearTeamId:
          type: string
        key:
          type: string
        name:
          type: string
        teamId:
          type: string
          format: uuid
          description: The team of this workspace its issues go to.
        state:
          type: string
          enum: [pending, fetching, done, skipped, failed]
          description: >-
            `done` made an import job, `skipped` had nothing to import, `failed` says
            why in `code` and `message`.
        issues:
          type: integer
          description: Issues read so far.
        comments:
          type: integer
          description: Comments read so far, then the number that went into the file.
        importId:
          type: string
          format: uuid
          description: The import job this team became. Present when `state` is `done`.
        code:
          type: string
          description: >-
            With `skipped` or `failed`: `no_issues`, `too_many_issues`,
            `file_too_large`, `linear_error`, `too_many_unfinished`, `not_fetched` or
            `internal`.
        message:
          type: string
          description: Why, in words for a person.
        notes:
          type: array
          description: What could not be carried across, in words. Empty when everything was.
          items:
            type: string

    ImportMapping:
      type: object
      required: [v, columns]
      description: What the person decided on the mapping screen.
      properties:
        v:
          type: integer
          enum: [1]
        columns:
          type: object
          description: >-
            Field to the file header it is read from. The fields are `title`
            (required), `description`, `status`, `priority`, `assignee`,
            `creator`, `labels`, `created`, `completed`, `due`, `estimate`,
            `parent`, `project`, `comments`, `attachments`, `sprints`, `cycleNumber`,
            `cycleStart`, `cycleEnd` and `statusCategory` (the lane the source gives
            a status, such as Jira's To Do, In Progress or Done, which decides
            the lane of a new status). A header can feed only one field. When a
            file repeats a header, the repeats are numbered `Labels (2)`,
            `Labels (3)`: they are read with the header they repeat, so every
            label column counts, and for a single value field the first
            non-empty one is used. A repeat that is mapped to a field of its own
            is left to that field. `comments` and `attachments` are the
            exceptions: every repeat is one comment, or one attachment.

            `attachments` reads Jira's Attachment cell, `date;author;filename;link`
            (shorter forms, a bare link or a bare filename are understood). The
            export holds no files and its links need a Jira login, so nothing is
            downloaded: the attachments are listed as links under an
            **Attachments** heading at the end of the description, with the
            author (when it is a name and not an account id) and the date. A link
            that is not http or https, or is over 2,048 bytes, is not made a
            link. At most 50 are listed (`too_many_attachments`). An image macro
            in a description or comment that names one of them, with `markup`
            `jira`, links to it. Issues imported earlier are not updated.

            `sprints` reads Jira's Sprint columns (repeats are read). Each sprint
            some issue ends up in becomes a cycle of the team, matched to one the
            team has by name (case and spacing ignored) and otherwise made. An
            issue belongs to one cycle, so one that was in several sprints joins
            the last (`plan.multiSprint` counts them). The export holds no dates:
            the review screen asks for them (`sprintDates`), and a sprint with
            none becomes a cycle with none. The team must have Cycles on, or the
            preview is refused (422 `invalid_mapping`) and the suggested mapping
            leaves the column out. Issues imported earlier keep the cycle they have.

            `cycleNumber`, `cycleStart` and `cycleEnd` are Linear's cycle columns
            (with `sprints` as its Cycle Name). A Linear cycle carries its dates,
            so nothing is typed: the cycle is called by its name when it has one,
            else `Cycle` and its number, else `Cycle` and its start day, and it is
            made with the days the file writes (`cycleStart` and `cycleEnd` are
            mapped together or not at all). Dates that are missing, half there or
            unreadable give a cycle without dates and a warning
            (`cycle_dates_unusable`). Two cycles with one name and different
            dates, which happens when a workspace numbers cycles by team, are told
            apart by adding the dates to their names. A cycle on exactly the days
            of one the team has joins it, whatever it is called. A start that a
            team cycle (or an earlier cycle of the file) already has, with other
            end days, does not refuse the preview: that cycle is left out with a
            warning (`cycle_start_taken`) and its issues import without one.
            Cycles that have ended are made as already rolled over.
          additionalProperties:
            type: string
        idColumn:
          type: string
          description: >-
            The header holding each row's id in its source system. Without it
            rows are recognised by their content, which protects a re-upload of
            the same file and nothing more (`plan.weakIdentity`). Required for
            `parent` links.
        idAlias:
          type: string
          description: >-
            A second header that also names each row, used only to resolve
            `parent` links. Jira rows are identified by their Issue key while
            its parent column holds the numeric Issue id. A parent that is
            itself an id column value means that row, an alias value that names
            two rows is dropped with a warning (`parent_ambiguous`). Requires
            `idColumn` and must differ from it and from every mapped column.
        people:
          type: object
          description: >-
            Gives the issues of a person who is not a member of the workspace to
            a member who is. The key is the person as the file writes them,
            trimmed and lowercased, and the value the id of a member. Only the
            assignee is ever given: who created an issue is history and falls
            back to whoever started the import, as before. A person who matches a
            member is never remapped, and a member who is not in the workspace
            (gone since the review, or from another workspace) is ignored with a
            note (`person_choice_stale`) and the issues stay unassigned, which
            changes the plan, so the run is refused and reviewed again. A name
            shared by several members can be settled this way. At most 500, and a
            key that is not trimmed and lowercase is refused with a 422.
          additionalProperties:
            type: string
        strict:
          type: boolean
          description: >-
            Leave a whole issue out when one of its optional values cannot be
            used. Off by default: an estimate, a date, a label or a project that
            cannot be used is dropped, a note (a warning, not an error) says so,
            and the issue is imported. A date that is only a day, with no time,
            is stored as noon UTC of that day so that it is the same day for
            everyone. A title, an ID, a status or a priority that cannot be used
            leaves the row out either way.
        sprintDates:
          type: object
          description: >-
            The dates given to each sprint, keyed by the sprint's name lowercased,
            with runs of spaces made one and trimmed. Both dates or neither, as
            `YYYY-MM-DD`, the end not before the start, and no two sprints
            starting on one day (a team cannot have two cycles that do). They
            apply only to cycles the import makes. A cycle that has already ended
            is made as already rolled over, so the team's rollover does not move
            the imported work. A start that another cycle of the team has
            refuses the preview.
          additionalProperties:
            type: object
            properties:
              start:
                type: string
                format: date
              end:
                type: string
                format: date
        linkColumns:
          type: array
          items:
            type: string
          description: >-
            Jira's issue link columns, each listed once, such as
            `Outward issue link (Blocks)`. A repeat of one (`... (Blocks) (2)`)
            is read with it. The link type and direction come from the header:
            Blocks is a blocks link, Duplicate a duplicate link (the row is the
            copy, for outward), Relates and Cloners are related links, and any
            other type is imported as related with a warning
            (`link_type_related`). The cell holds the key of the issue at the
            other end, so `idColumn` is required and the header must not be
            used for anything else. A link whose other end is not in the file or
            an earlier import, a link to itself, and a second duplicate target
            for one issue are left out with a warning
            (`link_target_not_found`, `link_is_self`, `link_duplicate_extra`).
            One the rules of Tasks refuse when it is made (it would close a loop
            of blocks, the issues are in different teams) is reported against
            its row as `link_rejected` and the issue is imported anyway.
        markup:
          type: string
          enum: [none, jira]
          default: none
          description: >-
            The formatting the text of descriptions and comments is written in.
            `none` keeps it as written. `jira` converts Jira wiki markup to
            Markdown: headings, bold, strike-through, nested bullet and numbered
            lists, links, quotes and panels, tables, code and noformat blocks,
            inline monospace, colors, mentions and forced line breaks. Code is
            never rewritten, text that is not markup comes out unchanged, and
            markup it does not understand (underline, sub and superscript,
            emoticons, macros) is left as written. Images are attachments, which
            do not come across, so an image becomes its file name. An account
            mention becomes `@user`. Lengths are checked after converting.
        commentFormat:
          type: string
          enum: [plain, jira]
          default: plain
          description: >-
            How a `comments` cell is laid out. `plain`: the cell is the comment,
            written by whoever runs the import, dated when the issue was filed.
            `jira`: `12/Mar/24 10:15 AM;<author>;<text>`, split at the first two
            semicolons only. The date is read as the mapping's date format and
            time zone. An author that is a member, by email or a unique name,
            keeps the comment. One that is not is written as `*Originally by
            name*` when it is a readable name, and left out when it is a Jira
            account id. A cell that does not fit the layout is added whole with a
            warning (`comment_unreadable`), and one with an unreadable date keeps
            its text and loses the date (`comment_bad_date`). A comment never
            makes its issue invalid. Over 500 per issue, or a comment over
            100,000 characters, is left out with a warning.
        completedFallback:
          type: string
          description: >-
            A second header read when the completed column is empty. Linear
            writes the date a canceled issue was closed in its `Canceled`
            column and leaves `Completed` empty. It must differ from the
            completed column.
        dateFormat:
          type: string
          enum: [iso, dmy, mdy]
          default: iso
          description: >-
            How numeric dates are read. Never guessed: 03/04/2024 is the third
            of April under `dmy` and the fourth of March under `mdy`.
        timezone:
          type: string
          description: IANA name for dates with no zone of their own. UTC when empty.
          example: Asia/Kolkata
        labelSeparator:
          type: string
          maxLength: 3
          description: Splits the labels cell. A comma when empty.
        status:
          type: object
          description: >-
            Overrides for status values, keyed by the value lowercased and
            trimmed.
          additionalProperties:
            $ref: "#/components/schemas/ImportStateTarget"
        priority:
          type: object
          description: >-
            Overrides for priority values, keyed by the value lowercased and
            trimmed, to 0 (none), 1 (urgent), 2 (high), 3 (medium) or 4 (low).
          additionalProperties:
            type: integer
            minimum: 0
            maximum: 4

    ImportStateTarget:
      type: object
      description: Exactly one of `stateId` or `create`.
      properties:
        stateId:
          type: string
          format: uuid
          description: An existing status of the team.
        create:
          type: boolean
          description: Create a status with this value as its name.
        type:
          type: string
          enum: [backlog, unstarted, started, completed, canceled]
          description: The lane of a created status. Inferred from the name when omitted.

    ImportUpload:
      type: object
      required: [import, file, suggestedMapping]
      properties:
        import:
          $ref: "#/components/schemas/Import"
        file:
          type: object
          required: [headers, rowCount, sampleRows, delimiter, encoding]
          properties:
            headers:
              type: array
              items:
                type: string
            rowCount:
              type: integer
            sampleRows:
              type: array
              description: The first rows, as cells in header order.
              items:
                type: array
                items:
                  type: string
            delimiter:
              type: string
              description: The separator that was detected.
            encoding:
              type: string
              enum: [utf-8, utf-16le, utf-16be, windows-1252]
            warnings:
              type: array
              nullable: true
              description: Things about the file as a whole worth hearing, such as unreadable characters.
              items:
                type: string
        suggestedMapping:
          $ref: "#/components/schemas/ImportMapping"
        previousImport:
          type: object
          nullable: true
          description: Set when this exact file was imported into the team before.
          properties:
            id:
              type: string
              format: uuid
            finishedAt:
              type: string
              format: date-time
              nullable: true
            createdCount:
              type: integer
            namespace:
              type: string
        detected:
          type: object
          nullable: true
          description: >-
            Set when the file is recognised as another tool's export. It is
            advice for the screen: the mapping is still reviewed. The default
            source name is `linear` or `jira`, shared by every export of that
            tool so overlapping files skip each other's issues.
          required: [source, namespace, notes]
          properties:
            source:
              type: string
              enum: [linear, jira]
            namespace:
              type: string
              description: The source name the import defaults to, `linear` or `jira`.
            notes:
              type: array
              description: Things about this file worth knowing before importing it.
              items:
                type: string

    ImportInspection:
      type: object
      required: [file, teamColumn, teams, noTeamRows]
      properties:
        file:
          type: object
          required: [headers, rowCount, delimiter, encoding]
          properties:
            headers:
              type: array
              items:
                type: string
            rowCount:
              type: integer
            delimiter:
              type: string
            encoding:
              type: string
              enum: [utf-8, utf-16le, utf-16be, windows-1252]
            warnings:
              type: array
              nullable: true
              items:
                type: string
        detected:
          type: object
          nullable: true
          description: Set when the file is recognised as another tool's export, as on `ImportUpload`.
          required: [source, namespace, notes]
          properties:
            source:
              type: string
              enum: [linear, jira]
            namespace:
              type: string
            notes:
              type: array
              items:
                type: string
        teamColumn:
          type: string
          nullable: true
          description: The header the teams were read from, or null when the file has no such column.
        teams:
          type: array
          description: Each team the file names, in the order they first appear.
          items:
            type: object
            required: [name, rows, match]
            properties:
              name:
                type: string
                description: The first spelling in the file.
              rows:
                type: integer
              match:
                type: object
                nullable: true
                description: The team of the workspace this most likely is, or null.
                required: [id, name, key]
                properties:
                  id:
                    type: string
                    format: uuid
                  name:
                    type: string
                  key:
                    type: string
        noTeamRows:
          type: integer
          description: Rows whose team cell is empty. They go to no team.

    ImportBatch:
      type: object
      required: [batchId, teamColumn, jobs, leftOut]
      properties:
        batchId:
          type: string
          format: uuid
        teamColumn:
          type: string
          description: The header the teams were read from.
        jobs:
          type: array
          description: >-
            One upload per team, in the order the teams first appear in the file,
            each shaped as the answer to `createImport`.
          items:
            $ref: "#/components/schemas/ImportUpload"
        leftOut:
          type: array
          description: The rows that are in no import, and why.
          items:
            type: object
            required: [team, rows, reason]
            properties:
              team:
                type: string
                description: The name in the file, empty for rows that name no team.
              rows:
                type: integer
              reason:
                type: string
                enum: [left_out, no_team]
                description: left_out (the map said null) or no_team (the team cell is empty).
        warnings:
          type: array
          items:
            type: string
          description: Things about the file as a whole worth hearing.

    ImportProblem:
      type: object
      required: [row, code, message, severity]
      properties:
        row:
          type: integer
          description: The line of the file, as shown in a spreadsheet or editor.
        field:
          type: string
        code:
          type: string
        message:
          type: string
        severity:
          type: string
          enum: [error, warning]
          description: An error leaves the row out. A warning imports it with the issue noted.

    ImportPlan:
      type: object
      required:
        - identity
        - weakIdentity
        - total
        - create
        - skip
        - invalid
        - comments
        - commentsSkip
        - commentAuthorsUnmatched
        - attachments
        - newCycles
        - existingCycles
        - multiSprint
        - links
        - linksSkip
        - errorCount
        - warningCount
        - problems
        - problemsTruncated
        - newStates
        - newLabels
        - newProjects
        - unresolvedPeople
        - assigneesMapped
        - unresolvedPeopleTotal
        - unknownPriorities
        - unknownPrioritiesTotal
        - sample
        - fingerprint
      description: >-
        What an import would do, computed from the file, the mapping and the
        team as it is now. Counts are exact. Lists are cut to a screenful.
      properties:
        identity:
          type: string
          description: How rows are recognised on a re-run, `column:<header>` or `content`.
        weakIdentity:
          type: boolean
          description: >-
            True when rows have no id column. A re-upload of the same file is
            recognised, an edited file is not.
        total:
          type: integer
        create:
          type: integer
          description: Rows that would be written.
        skip:
          type: integer
          description: Rows already imported under this namespace.
        invalid:
          type: integer
          description: Rows left out because of an error.
        comments:
          type: integer
          description: >-
            Comments that would be written, on new issues and on issues
            imported before (a re-run adds the comments that are new).
        commentsSkip:
          type: integer
          description: Comments already imported under this namespace.
        attachments:
          type: integer
          description: Attachments that would be listed, as links, at the ends of descriptions.
        newCycles:
          type: array
          description: >-
            Cycles the import would make, one per sprint some issue ends up in.
            Dated ones first, in date order, then the rest in the order the file
            meets them, which is the order they are numbered in. `start` and `end`
            are present only when dates were given.
          items:
            type: object
            required: [name, rows]
            properties:
              name:
                type: string
              rows:
                type: integer
                description: How many issues join it.
              fromFile:
                type: boolean
                description: >-
                  The dates are the file's own (a Linear cycle) and cannot be
                  changed on the review screen.
              start:
                type: string
                format: date
              end:
                type: string
                format: date
        existingCycles:
          type: array
          description: >-
            Cycles the team already has that the file's sprints match by name, or
            that a file's cycles match by their start and end days.
          items:
            $ref: "#/components/schemas/ImportNewThing"
        multiSprint:
          type: integer
          description: Issues that were in more than one sprint and join the last.
        links:
          type: integer
          description: >-
            Links that would be made, between issues in this file and issues
            imported before. A re-import adds any the file has that are missing,
            including one that was removed by hand.
        linksSkip:
          type: integer
          description: >-
            Links already there, whoever made them, and links named from both
            ends that count once.
        commentAuthorsUnmatched:
          type: integer
          description: >-
            Of `comments`, those in the `jira` layout whose author is not a
            member of the workspace and who will appear as whoever runs the import.
        errorCount:
          type: integer
        warningCount:
          type: integer
        problems:
          type: array
          description: >-
            Up to 100: the errors first, then the notes, each in row order.
            `errorCount` and `warningCount` are exact however many are shown.
          items:
            $ref: "#/components/schemas/ImportProblem"
        problemsTruncated:
          type: boolean
        newStates:
          type: array
          description: Statuses that would be created, only for rows that will be written.
          items:
            type: object
            required: [name, type, rows]
            properties:
              name:
                type: string
              type:
                type: string
                enum: [backlog, unstarted, started, completed, canceled]
              rows:
                type: integer
        newLabels:
          type: array
          items:
            $ref: "#/components/schemas/ImportNewThing"
        newProjects:
          type: array
          items:
            $ref: "#/components/schemas/ImportNewThing"
        unresolvedPeople:
          type: array
          description: >-
            People in the file who are not members. Their issues are created
            unassigned, and a missing creator falls back to whoever started the
            import, unless the admin gave them to a member (`people` on the
            mapping), in which case they stay listed with the member. The first
            200, and `unresolvedPeopleTotal` is how many there are.
          items:
            type: object
            required: [value, rows, assigneeRows]
            properties:
              value:
                type: string
              rows:
                type: integer
                description: The issues that name them, as assignee or as creator.
              assigneeRows:
                type: integer
                description: >-
                  The issues that name them as assignee, the only ones that can
                  be given to a member.
              assignedTo:
                type: string
                description: The member chosen for them, when that member is still in the workspace.
        unresolvedPeopleTotal:
          type: integer
        assigneesMapped:
          type: integer
          description: The issues that will be created with an assignee the admin chose for a person who is not a member.
        unknownPriorities:
          type: array
          description: >-
            Priority values in the file that are not recognised (anything but
            0 to 4 or the common words), each with its row count. Rows carrying
            one are left out until it is mapped, in the mapping's `priority`
            object. The first 50.
          items:
            $ref: "#/components/schemas/ImportNewThing"
        unknownPrioritiesTotal:
          type: integer
        sample:
          type: array
          description: The first rows that would be written, as they would be.
          items:
            $ref: "#/components/schemas/ImportPlanRow"
        warnings:
          type: array
          nullable: true
          items:
            type: string
        fingerprint:
          type: string
          description: Identifies this plan. Send it to commit to approve exactly this.

    ImportNewThing:
      type: object
      required: [name, rows]
      properties:
        name:
          type: string
        rows:
          type: integer
          description: How many rows that will be written use it.

    ImportPlanRow:
      type: object
      required: [row, action, sourceId, title]
      properties:
        row:
          type: integer
        action:
          type: string
          enum: [create, invalid]
        sourceId:
          type: string
        title:
          type: string
        description:
          type: string
          description: Cut to 200 characters.
        state:
          type: object
          description: An existing status (`id`) or one that would be created (`newName`, `newType`).
          properties:
            id:
              type: string
              format: uuid
            newName:
              type: string
            newType:
              type: string
        priority:
          type: integer
        labels:
          type: array
          nullable: true
          items:
            type: string
        project:
          type: string
        assigneeResolved:
          type: boolean
        assigneeText:
          type: string
          description: The assignee as written, when it did not match a member.
        createdAt:
          type: string
          format: date-time
          nullable: true
        completedAt:
          type: string
          format: date-time
          nullable: true
        dueDate:
          type: string
        estimate:
          type: integer
          nullable: true
        parentSourceId:
          type: string

    ImportWaitingPerson:
      type: object
      description: >-
        Someone an import named as assignee who is not a member of the workspace
        yet. Their issues are handed to them when they are added.
      required: [name, issues]
      properties:
        name:
          type: string
          description: The person as the file wrote them (an email address or a name).
        issues:
          type: integer
          description: How many of the import's issues are waiting for them.
    ImportRowError:
      type: object
      required: [row, code, message]
      properties:
        row:
          type: integer
          description: 0 for a problem with the import as a whole.
        field:
          type: string
        code:
          type: string
          description: >-
            `insert_failed`, `parent_missing`, `parent_refused`, `plan_changed`,
            `unusable` or `gave_up`.
        message:
          type: string
