openapi: 3.0.3

info:
  title: Qunli API
  version: "1.0.0"
  description: |
    Machine-readable contract for the Qunli collaborative workspace API.

    Conventions (all endpoints):
    - Success is wrapped: `{ "data": <resource|array|null> }`; paginated
      collections add `{ "meta": { page, limit, total, total_pages, has_next } }`.
      There is no 204 — empty success is `200 { "data": null }`.
    - Errors are RFC 9457 problem details (`application/problem+json`) with a
      top-level `code` for client dispatch; there is no `{ "error": {...} }`
      wrapper. Branch on HTTP status, not body shape.
    - Resource create/update bodies wrap attributes under the resource key
      (e.g. `{ "project": { ... } }`).
    - snake_case fields, UUID string internal ids, decimal-string public ids,
      ISO 8601 (`Z`) timestamps; clients must
      tolerate unknown fields (additive changes are backward compatible).
    - Identifiers: recordings are addressed in URL paths by their typed
      `public_id` ref — a type prefix plus per-account number ("T1042" task,
      "M…" message, "D…" document, "G…" goal, "E…" forwarded email,
      "C…" comment; case-insensitive, leading "#" tolerated). Numeric `id`
      UUID fields stay inside payloads (commentable_id, move.task_id, …). Bare
      numbers in paths are legacy recording-id lookups, deprecated for
      writes. `GET /recordings/{ref}` dereferences any ref to its full typed
      payload; canonical web URLs are
      `/<account.public_id>/<recording.public_id>`.

    A human-oriented discovery guide is served at `GET /api/v1/llms.txt`.
    AI agents working in a team should start with `GET /api/v1/agents.md` and
    the remote MCP server at `https://qunli.co/mcp`.

    Authentication: an API key or an OAuth access token (MCP clients), both as
    `Authorization: Bearer <token>`. They follow the same rules, so where an
    endpoint says "with an API key", an OAuth access token behaves the same.

    Clients may send optional X-Client-Name and X-Client-Version headers for
    performance attribution. Each label must be 1-64 ASCII characters, start
    with a letter or digit, and otherwise use letters, digits, dots,
    underscores, plus signs, hyphens or spaces. Invalid labels are ignored;
    these self-reported labels do not affect authentication or permissions.

# Paths carry their full prefix (/api/v1/…, /auth_api/…, /.well-known/…),
# so the server is the bare origin: https://qunli.co + /api/v1/accounts.
servers:
  - url: https://qunli.co
    description: Production

paths:
  /api/v1/accounts/{account_id}/api_keys:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: listApiKeys
      summary: List API keys for the current user in the given account
      tags: [api_keys]
      responses:
        "200":
          description: API key list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/ApiKey"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createApiKey
      summary: Create a new API key (plaintext token returned only on this call)
      description: >
        A person's own key. Agents get 403: an agent's keys are issued by a human
        (POST .../agents/{agent_id}/api_keys).
      tags: [api_keys]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [api_key]
              properties:
                api_key:
                  type: object
                  required: [name]
                  properties:
                    name:
                      type: string
                      maxLength: 255
      responses:
        "201":
          description: API key created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/ApiKeyWithToken"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /api/v1/accounts/{account_id}/api_keys/{id}:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
      - name: id
        in: path
        required: true
        schema: { type: string }
    delete:
      operationId: revokeApiKey
      summary: Revoke an API key (returns the now-revoked record)
      tags: [api_keys]
      responses:
        "200":
          description: Revoked API key
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/ApiKey"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/accounts/{account_id}/agents:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listManagedAgents
      summary: The running and paused agents the caller manages
      description: |
        Only agents whose managed_by is the caller (team admins manage other agents
        through the member directory). Each carries the directory fields with
        last_active_at, open_task_count (open tasks in projects the caller can read)
        and unresponded_count (assigned/mentioned notifications of the last two months,
        on recordings the caller can read, with no activity by the agent on that
        recording since — see GET .../agents/{id}/received). Not paginated.
      tags: [agents]
      responses:
        "200":
          description: Managed agents
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ManagedAgent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createAgent
      tags: [agents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent]
              properties:
                agent:
                  type: object
                  required: [name, managed_by_id]
                  properties:
                    name: { type: string }
                    tagline: { type: string, nullable: true }
                    managed_by_id: { type: string, format: uuid }
                    role: { type: string, enum: [owner, admin, member, external] }
                    avatar: { type: string }
      responses:
        "201":
          description: Agent created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/AccountUser" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  /api/v1/accounts/{account_id}/agents/{id}:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - {
          name: id,
          in: path,
          required: true,
          schema: { type: string },
          description: 'Agent user UUID or member ref ("U12"); bare numbers never resolve.',
        }
    get:
      operationId: getAgent
      tags: [agents]
      responses:
        "200":
          description: Agent member
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/AccountUser" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateAgent
      tags: [agents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent]
              properties:
                agent:
                  type: object
                  properties:
                    name: { type: string }
                    tagline: { type: string, nullable: true }
                    managed_by_id: { type: string, format: uuid }
                    role: { type: string, enum: [owner, admin, member, external] }
                    status: { type: string, enum: [active, paused] }
                    avatar: { type: string }
      responses:
        "200":
          description: Agent updated
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/AccountUser" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
        "409": { $ref: "#/components/responses/Conflict" }

  /api/v1/accounts/{account_id}/agents/{id}/received:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - {
          name: id,
          in: path,
          required: true,
          schema: { type: string },
          description: 'Agent user UUID or member ref ("U12").',
        }
    get:
      operationId: listAgentReceived
      summary: What an agent was assigned or mentioned on (manager only)
      description: |
        The agent's notifications with reason assigned or mentioned from the last two
        months, newest first, on recordings the caller can read. Only the agent's
        manager may call it (403 otherwise, team admins included); an agent reads its
        own inbox through GET .../notifications. responded is true when the agent has
        any activity (comment, status change, assignment, …) on the notification's
        recording after the notification; last_activity_at is the agent's latest
        activity there (null if none).
      tags: [agents]
      parameters:
        - { name: page, in: query, required: false, schema: { type: integer } }
        - { name: limit, in: query, required: false, schema: { type: integer } }
      responses:
        "200":
          description: Received items
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ReceivedNotification" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/agents/transfer_all:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
    post:
      operationId: transferAllAgents
      summary: Hand every agent one member manages to another human member
      description: |
        Moves all running and paused agents managed by from_user_id to to_user_id in one
        transaction. A human team owner/admin may do it for anyone; any other human only
        for their own agents. Agents cannot call it (403) and cannot become managers:
        to_user_id must be an active human member (422), different from from_user_id.
        Members are user UUIDs or member refs ("U12"). Answers with the moved agents
        (an empty array when there were none). Removing or leaving a team while still
        managing agents answers 409 conflict until they are transferred.
      tags: [agents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from_user_id, to_user_id]
              properties:
                from_user_id: { type: string }
                to_user_id: { type: string }
      responses:
        "200":
          description: The transferred agents
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/AccountUser" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  /api/v1/accounts/{account_id}/agents/{agent_id}/api_keys:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - {
          name: agent_id,
          in: path,
          required: true,
          schema: { type: string },
          description: 'Agent user UUID or member ref ("U12"); bare numbers never resolve.',
        }
    get:
      operationId: listAgentApiKeys
      tags: [agents, api_keys]
      responses:
        "200":
          description: Agent API key list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createAgentApiKey
      tags: [agents, api_keys]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [api_key]
              properties:
                api_key:
                  type: object
                  required: [name]
                  properties: { name: { type: string } }
      responses:
        "201":
          description: Agent API key created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/ApiKeyWithToken" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  /api/v1/accounts/{account_id}/agents/{agent_id}/api_keys/{id}:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - {
          name: agent_id,
          in: path,
          required: true,
          schema: { type: string },
          description: 'Agent user UUID or member ref ("U12"); bare numbers never resolve.',
        }
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      operationId: revokeAgentApiKey
      tags: [agents, api_keys]
      responses:
        "200":
          description: Agent API key revoked
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/ApiKey" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/agents/{agent_id}/oauth_grants:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - {
          name: agent_id,
          in: path,
          required: true,
          schema: { type: string },
          description: 'Agent user UUID or member ref ("U12"); bare numbers never resolve.',
        }
    get:
      operationId: listAgentOauthGrants
      tags: [agents, oauth]
      description: |
        The OAuth authorizations the agent holds — one per approval on the
        consent page (`/auth/connect`), newest first, revoked ones included
        (with `revoked_at`). Approvals whose code was never exchanged are not
        listed. Same audience as the agent's API keys: a human who is an
        active team owner/admin or the agent's manager. Never carries a token.
      responses:
        "200":
          description: Agent OAuth grant list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/AgentOauthGrant" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/agents/{agent_id}/oauth_grants/{id}:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - {
          name: agent_id,
          in: path,
          required: true,
          schema: { type: string },
          description: 'Agent user UUID or member ref ("U12"); bare numbers never resolve.',
        }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      operationId: revokeAgentOauthGrant
      tags: [agents, oauth]
      description: |
        Revokes the authorization: its access token and refresh token stop
        working at once. The row stays, with `revoked_at`; revoking again is a
        no-op.
      responses:
        "200":
          description: Agent OAuth grant revoked
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/AgentOauthGrant" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/billing:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getBilling
      summary: Current subscription snapshot (status, trial countdown, plan, usage)
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      tags: [billing]
      responses:
        "200":
          description: Subscription snapshot
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/SubscriptionSnapshot" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/task_types:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: listTaskTypes
      summary: List task types for the account
      tags: [task_types]
      responses:
        "200":
          description: Task type list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/TaskType"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createTaskType
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Create a task type
      tags: [task_types]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_type]
              properties:
                task_type:
                  $ref: "#/components/schemas/TaskTypeInput"
      responses:
        "201":
          description: Task type created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/TaskType"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /api/v1/accounts/{account_id}/task_types/{id}:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getTaskType
      summary: Fetch a task type
      tags: [task_types]
      responses:
        "200":
          description: Task type
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/TaskType"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateTaskType
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Update a task type
      tags: [task_types]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_type]
              properties:
                task_type:
                  $ref: "#/components/schemas/TaskTypeInput"
      responses:
        "200":
          description: Updated task type
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/TaskType"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"
    delete:
      operationId: deleteTaskType
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Delete a task type (409 while tasks outside the recycle bin use it; trashed tasks and those of deleted projects move to another type)
      tags: [task_types]
      responses:
        "200":
          description: Deleted (no content payload)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DataEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /api/v1/accounts/{account_id}/task_statuses:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: listTaskStatuses
      summary: List task statuses for the account
      tags: [task_statuses]
      responses:
        "200":
          description: Task status list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/TaskStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createTaskStatus
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Create a task status
      tags: [task_statuses]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_status]
              properties:
                task_status:
                  $ref: "#/components/schemas/TaskStatusInput"
      responses:
        "201":
          description: Task status created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/TaskStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /api/v1/accounts/{account_id}/task_statuses/{id}:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getTaskStatus
      summary: Fetch a task status
      tags: [task_statuses]
      responses:
        "200":
          description: Task status
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/TaskStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateTaskStatus
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Update a task status
      tags: [task_statuses]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_status]
              properties:
                task_status:
                  $ref: "#/components/schemas/TaskStatusInput"
      responses:
        "200":
          description: Updated task status
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/TaskStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"
    delete:
      operationId: deleteTaskStatus
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Delete a task status (409 while tasks outside the recycle bin have it or a task type uses it; trashed tasks and those of deleted projects move to another status)
      tags: [task_statuses]
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DataEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /api/v1/accounts/{account_id}/task_statuses/{id}/move:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
      - name: id
        in: path
        required: true
        schema: { type: string }
    put:
      operationId: moveTaskStatus
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Reorder a task status (legacy verb path; see api-conventions §9)
      tags: [task_statuses]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [position]
              properties:
                position: { type: integer }
                stage: { type: string }
      responses:
        "200":
          description: Reordered task status
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/TaskStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /api/v1/accounts/{account_id}/priorities:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: listPriorities
      summary: List priorities for the account
      tags: [priorities]
      responses:
        "200":
          description: Priority list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Priority"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createPriority
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Create a priority
      tags: [priorities]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [priority]
              properties:
                priority:
                  $ref: "#/components/schemas/PriorityInput"
      responses:
        "201":
          description: Priority created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/Priority"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /api/v1/accounts/{account_id}/priorities/{id}:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getPriority
      summary: Fetch a priority
      tags: [priorities]
      responses:
        "200":
          description: Priority
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/Priority"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updatePriority
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Update a priority
      tags: [priorities]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [priority]
              properties:
                priority:
                  $ref: "#/components/schemas/PriorityInput"
      responses:
        "200":
          description: Updated priority
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/Priority"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"
    delete:
      operationId: deletePriority
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Delete a priority (409 while tasks outside the recycle bin use it; trashed tasks and those of deleted projects move to the default priority)
      tags: [priorities]
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DataEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /api/v1/accounts/{account_id}/priorities/{id}/move:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
      - name: id
        in: path
        required: true
        schema: { type: string }
    put:
      operationId: movePriority
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Reorder a priority (legacy verb path; see api-conventions §9)
      tags: [priorities]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [position]
              properties:
                position: { type: integer }
      responses:
        "200":
          description: Reordered priority
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/Priority"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /api/v1/accounts/{account_id}/reaction_types:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: listReactionTypes
      summary: List reaction types for the account
      tags: [reaction_types]
      responses:
        "200":
          description: Reaction type list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/ReactionType"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createReactionType
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Create a reaction type
      tags: [reaction_types]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reaction_type]
              properties:
                reaction_type:
                  $ref: "#/components/schemas/ReactionTypeInput"
      responses:
        "201":
          description: Reaction type created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/ReactionType"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /api/v1/accounts/{account_id}/reaction_types/{id}:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getReactionType
      summary: Fetch a reaction type
      tags: [reaction_types]
      responses:
        "200":
          description: Reaction type
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/ReactionType"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateReactionType
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Update a reaction type
      tags: [reaction_types]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reaction_type]
              properties:
                reaction_type:
                  $ref: "#/components/schemas/ReactionTypeInput"
      responses:
        "200":
          description: Updated reaction type
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/ReactionType"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"
    delete:
      operationId: deleteReactionType
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Delete a reaction type (409 if it has reactions)
      tags: [reaction_types]
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DataEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /api/v1/accounts/{account_id}/reaction_types/{id}/move:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
      - name: id
        in: path
        required: true
        schema: { type: string }
    put:
      operationId: moveReactionType
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      summary: Reorder a reaction type (legacy verb path; see api-conventions §9)
      tags: [reaction_types]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [position]
              properties:
                position: { type: integer }
      responses:
        "200":
          description: Reordered reaction type
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/ReactionType"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /api/v1/accounts/{account_id}/projects:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: listProjects
      summary: List accessible projects in the account
      tags: [projects]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, archived] }
      responses:
        "200":
          description: Project list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createProject
      summary: Create a project
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project]
              properties:
                project:
                  $ref: "#/components/schemas/ProjectInput"
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Project created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/UnprocessableContent"

  /api/v1/accounts/{account_id}/projects/{id}:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getProject
      summary: Fetch a project
      tags: [projects]
      responses:
        "200":
          description: Project
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateProject
      summary: Update a project
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project]
              properties:
                project:
                  $ref: "#/components/schemas/ProjectInput"
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated project
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableContent"
    delete:
      operationId: deleteProject
      summary: Delete a project (into 已删除的项目 for 30 days)
      description: >-
        Moves the project to the team's deleted projects: it disappears for
        everyone at once and can be restored for 30 days (POST
        /trashed_projects/{id}/restoration), then it is purged with
        everything in it. Human project admins and team owners/admins only;
        robots get 403. To keep a finished project readable, archive it
        instead (PATCH status archived).
      tags: [projects]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: The deleted project
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/TrashedProject" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ---- recycle bin (回收站) --------------------------------------------------
  /api/v1/accounts/{account_id}/projects/{project_id}/trashed_recordings:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: project_id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listTrashedRecordings
      tags: [recycle_bin]
      summary: List a project's recycle bin (回收站)
      description: >-
        The project's deleted tasks, discussions, documents, files, folders,
        schedules, goals and forwarded emails, most recently deleted first.
        Each stays here for 30 days (purge_at) and is then purged for good.
        A folder's contents and the later meetings of a "cancel following"
        are not listed: they went in with the folder / first meeting
        (items_count) and come back or go with it. Everyone who can see the
        project sees its bin (a deleted draft only its author); can_restore
        and can_purge say what the caller may do. Comments are not listed:
        a deleted comment is restored with PUT /recordings/{ref}/status/active
        by its author.
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
      responses:
        "200":
          description: Recycle bin entries
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/TrashedRecording" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/projects/{project_id}/trashed_recordings/{id}:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: project_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, description: The entry's ref ("T1042"), schema: { $ref: "#/components/schemas/RecordingRef" } }
    delete:
      operationId: purgeTrashedRecording
      tags: [recycle_bin]
      summary: Purge a recycle bin entry now (立即彻底删除)
      description: >-
        Permanently deletes the entry with its comments and everything that
        went into the bin with it (a folder's contents). Cannot be undone.
        Human project admins and team owners/admins only (403 otherwise).
      responses:
        "200":
          description: Purged
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/projects/{project_id}/trashed_recordings/{id}/restoration:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: project_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, description: The entry's ref ("T1042"), schema: { $ref: "#/components/schemas/RecordingRef" } }
    post:
      operationId: restoreTrashedRecording
      tags: [recycle_bin]
      summary: Restore a recycle bin entry
      description: >-
        Brings the entry back where it was, with the same ref, comments and
        history: to the status it had (restores_to), in the same folder — or
        the drive root when that folder is gone. A task keeps its type, status
        and priority (deleting one of those while the task is in the bin
        reassigns it). What went in with it comes back too; a "restored"
        activity is recorded. Allowed for project writers who deleted or
        wrote it, the human 负责人 of the robot that deleted or wrote it,
        project admins and team owners/admins; a robot only restores what it
        deleted or wrote itself (403 otherwise). PUT
        /recordings/{ref}/status/active does the same for these types.
      responses:
        "200":
          description: The restored recording, as GET /recordings/{ref} returns it
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        anyOf:
                          - $ref: "#/components/schemas/Task"
                          - $ref: "#/components/schemas/DriveItem"
                          - $ref: "#/components/schemas/Recording"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /api/v1/accounts/{account_id}/trashed_projects:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listTrashedProjects
      tags: [recycle_bin]
      summary: List the team's deleted projects (已删除的项目)
      description: >-
        Deleted projects, most recent first. A deleted project is gone for
        everyone (its records, notifications, search hits, calendar entries;
        mail to its forwarding address bounces) and can be restored for 30
        days (purge_at), after which it is purged with everything in it.
        Team owners/admins see every deleted project; a project admin sees
        the ones they administer. Robots see none.
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
      responses:
        "200":
          description: Deleted projects
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/TrashedProject" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/trashed_projects/{id}:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      operationId: purgeTrashedProject
      tags: [recycle_bin]
      summary: Purge a deleted project now, with everything in it
      description: Cannot be undone. Human team owners/admins only (403 otherwise).
      responses:
        "200":
          description: Purged
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/trashed_projects/{id}/restoration:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: restoreTrashedProject
      tags: [recycle_bin]
      summary: Restore a deleted project
      description: >-
        Brings the project back as it was (active, or archived if it was
        archived), with all its content. Human project admins and team
        owners/admins.
      responses:
        "200":
          description: The restored project
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Project" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- messages (recording) -------------------------------------------------
  /api/v1/accounts/{account_id}/messages:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listAccountMessages
      summary: List message recordings across the account
      tags: [messages]
      description: >-
        Active messages in accessible projects, newest first (created_at DESC,
        id DESC). Follow meta.has_next with page + 1 to read all messages.
      parameters:
        - $ref: "#/components/parameters/ListContent"
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: limit, in: query, description: Clamped to 1–100., schema: { type: integer, default: 30 } }
      responses:
        "200":
          description: Message list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Recording" },
                          },
                      }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/messages/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/RecordingRef" },
        }
    get:
      operationId: getMessage
      tags: [messages]
      responses:
        "200":
          description: Message
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Recording" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateMessage
      tags: [messages]
      requestBody:
        required: true
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/MessageInput" } },
          }
      responses:
        "200":
          description: Updated message
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Recording" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/ContentConflict" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: deleteMessage
      summary: Soft-delete a message (returns the trashed recording)
      tags: [messages]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Trashed message
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Recording" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/projects/{project_id}/messages:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listProjectMessages
      tags: [messages]
      description: >-
        Active messages, newest first (created_at DESC, id DESC). Returns
        pagination meta; follow meta.has_next with page + 1 for older messages.
      parameters:
        - $ref: "#/components/parameters/ListContent"
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: limit, in: query, description: Clamped to 1–100., schema: { type: integer, default: 30 } }
      responses:
        "200":
          description: Message list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Recording" },
                          },
                      }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createMessage
      tags: [messages]
      requestBody:
        required: true
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/MessageInput" } },
          }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created message
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Recording" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  # ---- documents (recording) ------------------------------------------------
  /api/v1/accounts/{account_id}/documents:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listAccountDocuments
      tags: [documents]
      parameters:
        - $ref: "#/components/parameters/ListContent"
      responses:
        "200":
          description: Document list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Recording" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createAccountDocument
      summary: Create a document (account-scoped; project_id in the body)
      tags: [documents]
      requestBody:
        required: true
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/DocumentInput" } },
          }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created document
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Recording" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
  /api/v1/accounts/{account_id}/documents/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/RecordingRef" },
        }
    get:
      operationId: getDocument
      tags: [documents]
      responses:
        "200":
          description: Document
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Recording" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateDocument
      tags: [documents]
      requestBody:
        required: true
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/DocumentInput" } },
          }
      responses:
        "200":
          description: Updated document
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Recording" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/ContentConflict" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: deleteDocument
      summary: Soft-delete a document (returns the trashed recording)
      tags: [documents]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Trashed document
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Recording" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/documents/{id}/publication_recipients:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/RecordingRef" } }
    get:
      operationId: listDocumentPublicationRecipients
      summary: Preview who will be notified when the document is published
      description: Includes publication notifications and pending mentions as if the caller published it now, excluding the author, the caller, people without access, and (when the caller is an agent) other agents who would only get a publication notice or whose mention the caller's remaining hourly agent → agent allowance does not cover (mentions take the remaining slots in body order). The list is refreshed at publication time. Does not send notifications or spend the allowance.
      tags: [documents]
      responses:
        "200":
          description: Current publication recipients
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/UserRef" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/projects/{project_id}/documents:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listProjectDocuments
      tags: [documents]
      parameters:
        - $ref: "#/components/parameters/ListContent"
      responses:
        "200":
          description: Document list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Recording" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createProjectDocument
      tags: [documents]
      requestBody:
        required: true
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/DocumentInput" } },
          }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created document
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Recording" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  # ---- drive (文档和文件): documents, uploads, folders ---------------------
  # Drive items are Document / Upload / Folder recordings. Folders nest
  # (recording.parent_id); refs: D… document, F… upload, B… folder.
  /api/v1/accounts/{account_id}/projects/{project_id}/drive_items:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: project_id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listDriveItems
      tags: [drive]
      description: >-
        Lists one folder level (parent_id, blank = project root) or the whole
        tree (recursive=true). Folders always come first, then the requested
        sort. Every item carries its full fields — the same shape as the
        single-item endpoints (documents include their body, uploads their
        file URLs, folders their children_count). Drafts and trashed items
        are omitted.
      parameters:
        - $ref: "#/components/parameters/ListContent"
        - name: parent_id
          in: query
          description: Folder ref ("B12") or recording uuid; omit for the project root.
          schema: { type: string }
        - name: recursive
          in: query
          description: List every level of the project's drive (parent_id is ignored).
          schema: { type: boolean }
        - name: type
          in: query
          description: Comma-separated recordable types to include (Document, Upload, Folder).
          schema: { type: string }
        - name: sort
          in: query
          schema: { type: string, enum: [name, updated_at, created_at, byte_size, kind], default: updated_at }
        - name: direction
          in: query
          description: Defaults to asc for name/kind, desc otherwise.
          schema: { type: string, enum: [asc, desc] }
        - { name: page, in: query, schema: { type: integer, minimum: 1 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 100 } }
      responses:
        "200":
          description: Drive items
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/DriveItem" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/projects/{project_id}/uploads:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: project_id, in: path, required: true, schema: { type: string } }
    post:
      operationId: createUpload
      tags: [drive]
      description: >-
        Uploads a file into the project's drive. API clients send the file
        itself as multipart/form-data (upload[file], ≤100MB; bodies above that
        are refused with 413 payload_too_large before processing). The JSON
        form with a blob signed_id is the webapp's browser flow (session-only
        Active Storage direct upload, where regular files are capped at 100MB
        and videos at 500MB). recording[title] defaults to the filename;
        recording[parent_id] places the file in a folder.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [upload]
              properties:
                upload:
                  type: object
                  required: [file]
                  properties:
                    file: { type: string, format: binary }
                    thumbnail: { type: string, format: binary }
                recording:
                  type: object
                  properties:
                    title: { type: string }
                    parent_id: { type: string, nullable: true }
          application/json:
            schema:
              type: object
              required: [upload]
              properties:
                upload:
                  type: object
                  properties:
                    id: { type: string, format: uuid }
                    file: { type: string, description: Active Storage blob signed_id (webapp browser flow only) }
                    thumbnail: { type: string, description: signed_id of a client-rendered JPEG/PNG/WebP thumbnail }
                recording:
                  type: object
                  properties:
                    id: { type: string, format: uuid }
                    title: { type: string }
                    parent_id: { type: string, nullable: true }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created upload
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/DriveItem" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/ContentTooLarge" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  /api/v1/accounts/{account_id}/uploads/{id}:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/RecordingRef" } }
    get:
      operationId: getUpload
      tags: [drive]
      responses:
        "200":
          description: Upload
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/DriveItem" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: renameUpload
      tags: [drive]
      description: Renames the file (recording.title). The original filename is kept in `filename`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DriveItemInput" }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated upload
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/DriveItem" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: trashUpload
      tags: [drive]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Trashed upload
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/DriveItem" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/uploads/{id}/download:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/RecordingRef" } }
    get:
      operationId: downloadUpload
      tags: [drive]
      description: >-
        302 to a short-lived storage URL served as an attachment, named after
        the current title (original extension kept). Follow the redirect
        without forwarding the Authorization header.
      responses:
        "302": { $ref: "#/components/responses/StorageRedirect" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/uploads/{id}/raw:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/RecordingRef" } }
    get:
      operationId: rawUpload
      tags: [drive]
      description: 302 to a short-lived inline storage URL (Range-capable; for img/video/audio elements).
      responses:
        "302": { $ref: "#/components/responses/StorageRedirect" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/uploads/{id}/content:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/RecordingRef" } }
    get:
      operationId: uploadContent
      tags: [drive]
      description: >-
        For pdf / text / spreadsheet uploads: 302 with a relative Location to
        the same-origin Active Storage proxy (always disposition=attachment,
        Range-capable) so browser previews can fetch() the bytes without
        bucket CORS. 404 for other kinds.
      responses:
        "302": { $ref: "#/components/responses/StorageRedirect" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/uploads/{id}/thumbnail:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/RecordingRef" } }
    get:
      operationId: uploadThumbnail
      tags: [drive]
      description: 302 to a ≤400px thumbnail (image variant, or the client-rendered video/PDF thumbnail); 404 when there is none.
      responses:
        "302": { $ref: "#/components/responses/StorageRedirect" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/uploads/{id}/preview:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/RecordingRef" } }
    get:
      operationId: uploadPreview
      tags: [drive]
      description: Images only — 302 to a ≤2048px variant (the original when the image is too large to process).
      responses:
        "302": { $ref: "#/components/responses/StorageRedirect" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/projects/{project_id}/folders:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: project_id, in: path, required: true, schema: { type: string } }
    post:
      operationId: createFolder
      tags: [drive]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/DriveItemInput"
                - type: object
                  properties:
                    folder:
                      type: object
                      properties: { id: { type: string, format: uuid } }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created folder
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/DriveItem" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  /api/v1/accounts/{account_id}/folders/{id}:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/RecordingRef" } }
    get:
      operationId: getFolder
      tags: [drive]
      responses:
        "200":
          description: Folder (with its ancestors)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/DriveItem" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: renameFolder
      tags: [drive]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DriveItemInput" }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated folder
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/DriveItem" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: trashFolder
      tags: [drive]
      description: Trashes the folder and everything below it.
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Trashed folder
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/DriveItem" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/recordings/{recording_id}/parent:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - $ref: "#/components/parameters/RecordingRefParam"
    patch:
      operationId: moveDriveItem
      tags: [drive]
      description: >-
        Moves a document, upload or folder into a folder of the same project
        (parent.id = folder ref or uuid) or to the project root (parent.id =
        null). A folder cannot move into itself or its descendants (422).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [parent]
              properties:
                parent:
                  type: object
                  properties:
                    id: { type: string, nullable: true }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: The moved item
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/DriveItem" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  # ---- tasks (recording) ----------------------------------------------------
  /api/v1/accounts/{account_id}/tasks:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listAccountTasks
      tags: [tasks]
      parameters:
        - $ref: "#/components/parameters/ListContent"
        - $ref: "#/components/parameters/PageParam"
        - name: limit
          in: query
          description: Rows per page (default 100); values outside 1–1000 are clamped into that range.
          schema: { type: integer, default: 100 }
        - name: assignee_id
          in: query
          description: Keep tasks assigned to this user (UUID). Repeat as assignee_id[] for several.
          schema:
            oneOf:
              - { type: string, format: uuid }
              - { type: array, items: { type: string, format: uuid } }
        - name: task_type_id
          in: query
          description: Keep tasks of this task type (UUID). Repeat as task_type_id[] for several.
          schema:
            oneOf:
              - { type: string, format: uuid }
              - { type: array, items: { type: string, format: uuid } }
        - name: task_status_id
          in: query
          description: Keep tasks in this status (UUID). Repeat as task_status_id[] for several.
          schema:
            oneOf:
              - { type: string, format: uuid }
              - { type: array, items: { type: string, format: uuid } }
        - name: priority_id
          in: query
          description: Keep tasks with this priority (UUID). Repeat as priority_id[] for several.
          schema:
            oneOf:
              - { type: string, format: uuid }
              - { type: array, items: { type: string, format: uuid } }
        - name: scope
          in: query
          description: "all (default), mine (assigned to me), or members (assigned to any member_ids). Intersects existing filters."
          schema: { type: string }
        - $ref: "#/components/parameters/CalendarMembers"
        - name: project_ids
          in: query
          description: Comma-separated project refs/UUIDs; intersects accessible projects.
          schema: { type: string }
        - name: due_from
          in: query
          description: Keep tasks whose due_date is on or after this date (YYYY-MM-DD, inclusive). Tasks without a due_date are excluded whenever due_from or due_to is given.
          schema: { type: string, example: "2026-09-28" }
        - name: due_to
          in: query
          description: Keep tasks whose due_date is on or before this date (YYYY-MM-DD, inclusive).
          schema: { type: string, example: "2026-11-01" }
        - name: assignee
          in: query
          description: "`me` keeps tasks assigned to the caller (the only supported value)."
          schema: { type: string, example: me }
        - name: finishing
          in: query
          description: "`not_finished` keeps open tasks; `finished` keeps completed or cancelled ones."
          schema: { type: string, example: not_finished }
      responses:
        "200":
          description: Task list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Task" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "400": { $ref: "#/components/responses/BadRequest" }
    post:
      operationId: createAccountTask
      summary: Create a task (account-scoped; project_id in the body)
      tags: [tasks]
      requestBody:
        required: true
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/TaskInput" } },
          }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created task
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Task" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
  /api/v1/accounts/{account_id}/tasks/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/RecordingRef" },
        }
    get:
      operationId: getTask
      tags: [tasks]
      responses:
        "200":
          description: Task
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Task" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateTask
      tags: [tasks]
      requestBody:
        required: true
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/TaskInput" } },
          }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated task
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Task" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/ContentConflict" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: deleteTask
      summary: Soft-delete a task (returns the trashed recording)
      tags: [tasks]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Trashed task
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Task" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/projects/{project_id}/tasks:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listProjectTasks
      tags: [tasks]
      parameters:
        - $ref: "#/components/parameters/ListContent"
        - $ref: "#/components/parameters/PageParam"
        - name: limit
          in: query
          description: Rows per page (default 100); values outside 1–1000 are clamped into that range.
          schema: { type: integer, default: 100 }
        - name: assignee_id
          in: query
          description: Keep tasks assigned to this user (UUID). Repeat as assignee_id[] for several.
          schema:
            oneOf:
              - { type: string, format: uuid }
              - { type: array, items: { type: string, format: uuid } }
        - name: task_type_id
          in: query
          description: Keep tasks of this task type (UUID). Repeat as task_type_id[] for several.
          schema:
            oneOf:
              - { type: string, format: uuid }
              - { type: array, items: { type: string, format: uuid } }
        - name: task_status_id
          in: query
          description: Keep tasks in this status (UUID). Repeat as task_status_id[] for several.
          schema:
            oneOf:
              - { type: string, format: uuid }
              - { type: array, items: { type: string, format: uuid } }
        - name: priority_id
          in: query
          description: Keep tasks with this priority (UUID). Repeat as priority_id[] for several.
          schema:
            oneOf:
              - { type: string, format: uuid }
              - { type: array, items: { type: string, format: uuid } }
        - name: scope
          in: query
          description: "all (default), mine (assigned to me), or members (assigned to any member_ids). Intersects existing filters."
          schema: { type: string }
        - $ref: "#/components/parameters/CalendarMembers"
        - name: project_ids
          in: query
          description: Comma-separated project refs/UUIDs; intersects accessible projects.
          schema: { type: string }
        - name: due_from
          in: query
          description: Keep tasks whose due_date is on or after this date (YYYY-MM-DD, inclusive). Tasks without a due_date are excluded whenever due_from or due_to is given.
          schema: { type: string, example: "2026-09-28" }
        - name: due_to
          in: query
          description: Keep tasks whose due_date is on or before this date (YYYY-MM-DD, inclusive).
          schema: { type: string, example: "2026-11-01" }
        - name: assignee
          in: query
          description: "`me` keeps tasks assigned to the caller (the only supported value)."
          schema: { type: string, example: me }
        - name: finishing
          in: query
          description: "`not_finished` keeps open tasks; `finished` keeps completed or cancelled ones."
          schema: { type: string, example: not_finished }
      responses:
        "200":
          description: Task list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Task" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: createProjectTask
      tags: [tasks]
      requestBody:
        required: true
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/TaskInput" } },
          }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created task
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Task" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  # ---- schedules (recording, ref S…) ---------------------------------------
  # A schedule (日程) is a dated event in a project. starts_at / ends_at are UTC
  # instants and ends_at is EXCLUSIVE; all-day events also carry starts_on /
  # ends_on (calendar dates in time_zone, ends_on INCLUSIVE). Lists are windowed
  # ([from, to), ≤ 92 days), not paginated, capped at 1000 items.
  /api/v1/accounts/{account_id}/projects/{project_id}/schedules:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: project_id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listProjectSchedules
      tags: [schedules]
      description: >-
        Active schedules of the project overlapping [from, to) — starts_at < to
        AND ends_at > from — ordered by starts_at (all-day first on ties). Omit
        both from and to for the current month in Asia/Shanghai. Not paginated:
        at most 1000 items; narrow the window for more. Items carry every field
        (same shape as GET /schedules/{id}).
      parameters:
        - $ref: "#/components/parameters/ListContent"
        - $ref: "#/components/parameters/ScheduleWindowFrom"
        - $ref: "#/components/parameters/ScheduleWindowTo"
      responses:
        "200":
          description: Schedules overlapping the window
          headers:
            X-Result-Truncated:
              description: True if more matching schedules exist; exactly at the cap is false.
              schema: { type: string, enum: ["true", "false"] }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Schedule" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: createSchedule
      tags: [schedules]
      description: >-
        Creates an active schedule. Timed events send schedule.starts_at (and
        optionally ends_at, default +1 hour); all-day events send all_day: true
        with starts_on (and optionally ends_on, inclusive). schedule.participants
        (member refs "U12" or user UUIDs) become participants: each is notified
        (reason invited) and auto-follows the schedule. The creator is not added
        as a participant.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScheduleInput" }
            examples:
              timed:
                value:
                  recording: { title: "Q4 规划评审", content_markdown: "议程：\n1. 回顾 Q3\n2. Q4 目标" }
                  schedule:
                    starts_at: "2026-10-20T14:00:00+08:00"
                    ends_at: "2026-10-20T15:30:00+08:00"
                    location: "3 楼会议室"
                    link: "https://meeting.tencent.com/dm/abc123"
                    participants: ["U1003", "U1004"]
              all_day_multi_day:
                value:
                  recording: { title: "国庆假期" }
                  schedule: { all_day: true, starts_on: "2026-10-01", ends_on: "2026-10-07" }
      responses:
        "201":
          description: Created schedule
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Schedule" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  /api/v1/accounts/{account_id}/schedules:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listAccountSchedules
      tags: [schedules]
      description: >-
        The account calendar: schedules overlapping [from, to) across every
        project the caller can access. scope=mine (default) keeps schedules the
        caller created or participates in; scope=all keeps every accessible
        one. project_ids narrows to some projects (inaccessible ids are
        ignored). Same window rules, order and 1000-item cap as the project
        list.
      parameters:
        - $ref: "#/components/parameters/ListContent"
        - $ref: "#/components/parameters/ScheduleWindowFrom"
        - $ref: "#/components/parameters/ScheduleWindowTo"
        - name: scope
          in: query
          description: "mine (default) = created by me or I participate; all = every accessible project; members = selected creators or participants (member_ids required)."
          schema: { type: string, example: mine }
        - name: project_ids
          in: query
          description: Comma-separated project IDs or project_ids[]=12&project_ids[]=13.
          schema:
            oneOf:
              - { type: string, example: "12,13" }
              - { type: array, items: { type: string } }
        - $ref: "#/components/parameters/CalendarMembers"
      responses:
        "200":
          description: Schedules overlapping the window
          headers:
            X-Result-Truncated:
              description: True if more matching schedules exist; exactly at the cap is false.
              schema: { type: string, enum: ["true", "false"] }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Schedule" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/accounts/{account_id}/schedules/{id}:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/RecordingRef" } }
    get:
      operationId: getSchedule
      tags: [schedules]
      responses:
        "200":
          description: Schedule
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Schedule" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateSchedule
      tags: [schedules]
      description: >-
        Updates any subset of fields. Sending starts_at without ends_at (or
        starts_on without ends_on) shifts the event and keeps its duration;
        sending both sets both as given. Changing the time
        notifies participants and the creator (reason rescheduled), records a
        rescheduled activity and re-plans reminders. schedule.participants, when
        present, replaces the participant list (same as PUT .../participants).
        recording.content_version guards the body like the other recording
        updates: stale → 409 content_conflict (current content_version in the
        problem body), nothing written; only the selected instance's version
        is checked when edit_scope is following.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScheduleInput" }
            examples:
              move:
                value: { schedule: { starts_at: "2026-10-21T14:00:00+08:00" } }
              to_all_day:
                value: { schedule: { all_day: true, starts_on: "2026-10-21" } }
      responses:
        "200":
          description: Updated schedule
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Schedule" } }
        "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" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: trashSchedule
      summary: Cancel (trash) a schedule — notifies participants and the creator
      tags: [schedules]
      description: >-
        Cancelling is trashing: the response is the schedule with status
        trashed; participants and the creator (except the caller) are notified
        with reason cancelled. Restore with PUT /recordings/{ref}/status/active.
      parameters:
        - name: edit_scope
          in: query
          description: "this (default) cancels one occurrence; following stops the series from this future occurrence, including its later exceptions. Restoring one meeting does not restart the series."
          schema: { type: string }
      responses:
        "200":
          description: Trashed schedule
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Schedule" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/schedules/{schedule_id}/participants:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: schedule_id, in: path, required: true, schema: { $ref: "#/components/schemas/RecordingRef" } }
    put:
      operationId: replaceScheduleParticipants
      tags: [schedules]
      description: >-
        Replaces the participant list. members are member refs ("U12") or user
        UUIDs of people who can access the project; an empty array clears it.
        Newly added people are notified (reason invited) and follow the
        schedule; removed people stop following it (unless they created it).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScheduleParticipantsInput" }
            examples:
              replace:
                value: { participants: { version: "<participants_version>", members: ["U1003", "U1004"] } }
      responses:
        "200":
          description: The schedule with its new participants
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Schedule" } }
        "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" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  /api/v1/accounts/{account_id}/calendar_feeds:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listCalendarFeeds
      tags: [schedules]
      summary: List only the current user's calendar subscriptions
      responses:
        "200":
          description: Subscriptions (no raw access tokens)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/CalendarFeed" } }
    post:
      operationId: createCalendarFeed
      tags: [schedules]
      summary: Create a revocable read-only calendar subscription with saved filters
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CalendarFeedInput" }
      responses:
        "201":
          description: Subscription with its one-time token URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CalendarFeed" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  /api/v1/accounts/{account_id}/calendar_feeds/{id}:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      operationId: revokeCalendarFeed
      tags: [schedules]
      summary: Revoke a calendar subscription
      responses:
        "200":
          description: "Revoked (`{ \"data\": null }`); the old subscription URL now answers 404."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/calendar_feeds/{id}/rotate:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: rotateCalendarFeed
      tags: [schedules]
      responses:
        "200":
          description: New URL; previous token immediately invalidated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CalendarFeed" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/calendar_subscriptions/{id}.ics:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: readCalendarSubscription
      tags: [schedules]
      security: []
      description: "Read-only ICS authenticated by the URL token. Rechecks owner membership and project access on every request. Rolling past 90 days and next year; cancelled events retain stable UIDs. External clients choose refresh timing."
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: RFC 5545 calendar
          content:
            text/calendar:
              schema: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/accounts/{account_id}/schedules/{schedule_id}/calendar.ics:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: schedule_id, in: path, required: true, schema: { type: string } }
    get:
      operationId: downloadScheduleCalendar
      tags: [schedules]
      summary: Download one meeting (requires normal session/API authentication and project access)
      responses:
        "200":
          description: RFC 5545 calendar containing one event
          content:
            text/calendar:
              schema: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- goals (recording) ----------------------------------------------------
  /api/v1/accounts/{account_id}/goals:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listAccountGoals
      tags: [goals]
      parameters:
        - $ref: "#/components/parameters/ListContent"
      responses:
        "200":
          description: Goal list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Goal" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/goals/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/RecordingRef" },
        }
    get:
      operationId: getGoal
      tags: [goals]
      responses:
        "200":
          description: Goal
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Goal" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateGoal
      tags: [goals]
      requestBody:
        required: true
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/GoalInput" } },
          }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated goal
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Goal" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/ContentConflict" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: deleteGoal
      summary: Soft-delete a goal (returns the trashed recording)
      tags: [goals]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Trashed goal
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Goal" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/projects/{project_id}/goals:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listProjectGoals
      tags: [goals]
      parameters:
        - $ref: "#/components/parameters/ListContent"
      responses:
        "200":
          description: Goal list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Goal" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createProjectGoal
      tags: [goals]
      requestBody:
        required: true
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/GoalInput" } },
          }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created goal
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Goal" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  # ---- recordings (master list) --------------------------------------------
  /api/v1/accounts/{account_id}/recordings:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listRecordings
      summary: Paginated list of recordings across accessible projects
      tags: [recordings]
      parameters:
        - $ref: "#/components/parameters/ListContent"
        - { name: limit, in: query, required: false, schema: { type: integer } }
        - { name: page, in: query, required: false, schema: { type: integer } }
      responses:
        "200":
          description: Recording page
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Recording" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/recordings/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/RecordingRef" },
        }
    get:
      operationId: getRecording
      summary: >-
        Dereference any recording ref ("T1042", "M15761") to its full typed
        payload — tasks/goals/forwarded emails carry their recordable fields;
        uploads (F…) and folders (B…) answer with the DriveItem shape;
        messages/documents are complete in the base recording shape;
        schedules (S…) answer with the Schedule shape.
      tags: [recordings]
      responses:
        "200":
          description: Recording (typed by recordable_type)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        anyOf:
                          - $ref: "#/components/schemas/Schedule"
                          - $ref: "#/components/schemas/Task"
                          - $ref: "#/components/schemas/Goal"
                          - $ref: "#/components/schemas/ForwardedEmail"
                          - $ref: "#/components/schemas/DriveItem"
                          - $ref: "#/components/schemas/Recording"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- reactions (nested under a recording) --------------------------------
  /api/v1/accounts/{account_id}/recordings/{recording_id}/reactions:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
    get:
      operationId: listReactions
      tags: [reactions]
      responses:
        "200":
          description: Reaction list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Reaction" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createReaction
      tags: [reactions]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reaction]
              properties:
                reaction:
                  type: object
                  required: [reaction_type_id]
                  properties:
                    reactable_id:
                      type: string
                      format: uuid
                      deprecated: true
                      description: Ignored — the reaction always targets the recording in the path.
                    reaction_type_id:
                      type: string
                      format: uuid
                      description: A reaction type of this team (422 otherwise).
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created reaction
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Reaction" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
  /api/v1/accounts/{account_id}/recordings/{recording_id}/reactions/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getReaction
      tags: [reactions]
      responses:
        "200":
          description: Reaction
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Reaction" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteReaction
      description: Only the caller's own reaction can be removed; others get 403.
      tags: [reactions]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- comments (nested under a recording) ---------------------------------
  /api/v1/accounts/{account_id}/recordings/{recording_id}/comments:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
    get:
      operationId: listComments
      tags: [comments]
      responses:
        "200":
          description: Comment list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Comment" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createComment
      tags: [comments]
      requestBody:
        required: true
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/CommentInput" } },
          }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created comment
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Comment" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
  /api/v1/accounts/{account_id}/recordings/{recording_id}/comments/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
      # The comment's own ref ("C973"); bare id for pre-backfill rows.
      - {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/RecordingRef" },
        }
    get:
      operationId: getComment
      tags: [comments]
      responses:
        "200":
          description: Comment
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Comment" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateComment
      tags: [comments]
      requestBody:
        required: true
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/CommentInput" } },
          }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated comment
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Comment" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/ContentConflict" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: deleteComment
      summary: Soft-delete a comment (returns the trashed recording)
      tags: [comments]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Trashed comment
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Comment" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- followings (nested under a recording) -------------------------------
  /api/v1/accounts/{account_id}/recordings/{recording_id}/followings:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
    get:
      operationId: listFollowings
      tags: [followings]
      responses:
        "200":
          description: Following list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Following" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createFollowing
      summary: Follow a recording (no body needed)
      tags: [followings]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created following
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Following" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
  /api/v1/accounts/{account_id}/recordings/{recording_id}/followings/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getFollowing
      tags: [followings]
      responses:
        "200":
          description: Following
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Following" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateFollowing
      description: Only the caller's own following; others get 403.
      tags: [followings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [following]
              properties:
                following:
                  type: object
                  description: >-
                    A following's topic and follower cannot change; topic_id
                    and user_id are accepted for compatibility and ignored.
                  properties:
                    topic_id: { type: string, format: uuid, deprecated: true }
                    user_id: { type: string, format: uuid, deprecated: true }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated following
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Following" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteFollowing
      description: Only the caller's own following can be removed; others get 403.
      summary: Unfollow (soft-delete; returns the trashed following)
      tags: [followings]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Trashed following
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Following" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- my_following (the current user's following of a recording) -----------
  /api/v1/accounts/{account_id}/recordings/{recording_id}/my_following:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
    get:
      operationId: getMyFollowing
      summary: The current user's following (data is null when not following)
      tags: [followings]
      responses:
        "200":
          description: Following or null
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    delete:
      operationId: deleteMyFollowing
      summary: Unfollow as the current user (returns the trashed following; data is null when not following)
      tags: [followings]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Trashed following, or null when the caller was not following
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        nullable: true
                        allOf: [{ $ref: "#/components/schemas/Following" }]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- bookmark (the current user's bookmark of a recording) ----------------
  /api/v1/accounts/{account_id}/recordings/{recording_id}/bookmark:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
    get:
      operationId: getBookmark
      summary: The current user's bookmark (data is null when not bookmarked)
      tags: [bookmarks]
      responses:
        "200":
          description: Bookmark or null
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createBookmark
      summary: Bookmark the recording as the current user
      tags: [bookmarks]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created bookmark
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Bookmark" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
    delete:
      operationId: deleteBookmark
      summary: Remove the bookmark (returns data null)
      tags: [bookmarks]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ---- notifications --------------------------------------------------------
  /api/v1/accounts/{account_id}/notifications:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listNotifications
      summary: Notifications for the current user (paged newest first, or after a cursor oldest first)
      description: |
        Without `after`: the last two months, newest first, paginated by `page`/`limit`
        (meta is PaginationMeta).

        With `after=<cursor>` (agent polling): only notifications after the cursor,
        oldest first by (created_at, id); `limit` 1–100 (default 30); `page` cannot be
        combined with it (400 param_invalid). Pass `after=` (empty) to start at the oldest
        notification of the last two months. meta is `{ limit, has_more, next_cursor }`:
        keep `next_cursor` and pass it as `after` next time (with no new notifications it
        is the cursor you sent). The cursor is opaque. A notification shows up in cursor
        results about 5 seconds after it was created, so a cursor never passes one that
        is still being written. Use this instead of `PUT .../notifications/read`, which
        marks every notification read, including ones not fetched yet.
        A stateless client can keep its position on the server with
        `GET|PUT .../notifications/cursor`.

        Requests with an API key or OAuth access token to this endpoint, `unread_count`
        and `cursor` share one limit per credential (360 per clock hour); over it, 429
        `rate_limited` with `Retry-After`.

        Notification kinds are reason + action: assigned/assigned, mentioned/mentioned
        (a mention in a comment is attached to the commented recording, with
        details.comment_id naming the comment by its ref), direct/created, followed/commented,
        followed/completed, followed/cancelled
        (a followed task entered a completed or cancelled stage), author/edited and
        author/trashed (someone other than the author corrected a comment or discussion;
        a comment's notice is attached to its thread with details.comment_id and
        details.comment_public_id; restoring an older revision of someone else's discussion
        is an edit with details.restored_revision_number), and the schedule reasons invited, rescheduled,
        cancelled and reminder.

        Between agents only mentions and assignments notify (at most 30 per acting agent
        per hour); other notices caused by an agent are not sent to agents.
      tags: [notifications]
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer } }
        - { name: page, in: query, required: false, schema: { type: integer } }
        - {
            name: after,
            in: query,
            required: false,
            description: "Opaque cursor from meta.next_cursor; empty string starts from the oldest notification.",
            schema: { type: string },
          }
      responses:
        "200":
          description: Notification page, or the notifications after a cursor
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                additionalProperties: false
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Notification" }
                  meta:
                    oneOf:
                      - $ref: "#/components/schemas/PaginationMeta"
                      - $ref: "#/components/schemas/NotificationCursorMeta"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /api/v1/accounts/{account_id}/notifications/unread_count:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: getUnreadNotificationCount
      tags: [notifications]
      responses:
        "200":
          description: Unread count
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [count]
                        properties: { count: { type: integer } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /api/v1/accounts/{account_id}/notifications/cursor:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: getNotificationCursor
      summary: The current member's saved notification cursor in this team
      description: |
        Where the current member left off in their notification feed, saved with
        `PUT .../notifications/cursor`. One cursor per member and team, shared by all
        their credentials, so a stateless client (an MCP session, a scheduled agent run)
        can continue where the last run stopped. `null` until first saved: start with
        `after=` (empty). API-key and OAuth requests share the notification polling rate limit.
      tags: [notifications]
      responses:
        "200":
          description: Saved cursor
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/NotificationSavedCursor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    put:
      operationId: saveNotificationCursor
      summary: Save the current member's notification cursor in this team
      description: |
        Saves a `meta.next_cursor` from `GET .../notifications?after=`. Save it once the
        notices before it are handled. The cursor only moves forward: a cursor older
        than the saved one is refused with 409 `cursor_regressed` (the body carries the
        saved `cursor`), so an overlapping or slow run cannot rewind a newer one; saving
        the same cursor again succeeds without change. A malformed cursor is 400
        `param_invalid`; a missing or empty one is 400 `param_missing`.
      tags: [notifications]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cursor]
              additionalProperties: false
              properties:
                cursor: { type: string, description: "A meta.next_cursor value." }
      responses:
        "200":
          description: Saved cursor
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/NotificationSavedCursor" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: cursor_regressed — older than the saved cursor; nothing was written.
          content:
            application/problem+json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ProblemDetails"
                  - type: object
                    required: [cursor]
                    properties:
                      cursor: { type: string }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /api/v1/accounts/{account_id}/notifications/read:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    put:
      operationId: markNotificationsRead
      tags: [notifications]
      responses:
        "200":
          description: All marked read
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/notifications/read_topic:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    put:
      operationId: markTopicNotificationsRead
      tags: [notifications]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                notifiable_type: { type: string }
                notifiable_id: { type: string, format: uuid }
      responses:
        "200":
          description: Topic marked read
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/notifications/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateNotification
      tags: [notifications]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [notification]
              properties:
                notification:
                  type: object
                  properties:
                    read_at: { type: string, format: date-time, nullable: true }
      responses:
        "200":
          description: Updated notification
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Notification" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  # ---- task_type_statuses (which statuses a task type enables) --------------
  /api/v1/accounts/{account_id}/task_type_statuses:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listTaskTypeStatuses
      tags: [task_type_statuses]
      responses:
        "200":
          description: Task type status list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items:
                              { $ref: "#/components/schemas/TaskTypeStatus" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/task_type_statuses/activate:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    put:
      operationId: activateTaskTypeStatus
      description: Both referenced resources must belong to this account; otherwise returns 404.
      tags: [task_type_statuses]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_type_status]
              properties:
                task_type_status:
                  type: object
                  properties:
                    task_type_id: { type: string, format: uuid }
                    task_status_id: { type: string, format: uuid }
      responses:
        "200":
          description: Activated link
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/TaskTypeStatus" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
  /api/v1/accounts/{account_id}/task_type_statuses/deactivate:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    put:
      operationId: deactivateTaskTypeStatus
      description: Both referenced resources must belong to this account; otherwise returns 404.
      tags: [task_type_statuses]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_type_status]
              properties:
                task_type_status:
                  type: object
                  properties:
                    task_type_id: { type: string, format: uuid }
                    task_status_id: { type: string, format: uuid }
      responses:
        "200":
          description: Deactivated (no content payload)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- user_accounts (the current user's memberships) -----------------------
  /api/v1/user_accounts:
    get:
      operationId: listUserAccounts
      description: The caller's team memberships; with an API key, only the key's own team.
      tags: [user_accounts]
      responses:
        "200":
          description: Membership list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/UserAccount" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createUserAccount
      deprecated: true
      summary: Direct membership creation is disabled; join through an invitation
      tags: [user_accounts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_account]
              properties:
                user_account:
                  type: object
                  properties:
                    user_id: { type: string, format: uuid }
                    account_id: { type: string, format: uuid }
                    role: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /api/v1/user_accounts/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getUserAccount
      description: With an API key, only the key's own team membership resolves.
      tags: [user_accounts]
      responses:
        "200":
          description: Membership
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/UserAccount" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateUserAccount
      tags: [user_accounts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_account]
              properties:
                user_account: { type: object }
      responses:
        "200":
          description: Updated membership
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/UserAccount" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: deleteUserAccount
      description: >-
        Leave a team (离开团队) — the same soft leave as being removed by an
        admin: the membership stays as `left`, so the person's content keeps
        their name, and their access to the team ends. Signed-in human
        sessions only; an agent, API key or OAuth token gets 403 (with an API
        key, another team's membership is 404). 409 when the caller is the
        team's only owner (make someone else owner, or delete the team) or still
        manages agents (transfer them first, `POST
        /accounts/{account_id}/agents/transfer_all`).
      tags: [user_accounts]
      responses:
        "200":
          description: Left
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  # ---- account users (account members, by user; /users and /account_users) --
  /api/v1/accounts/{account_id}/users:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listAccountUsers
      tags: [account_users]
      responses:
        "200":
          description: Account user list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/AccountUser" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/users/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: id,
          in: path,
          required: true,
          schema: { type: string },
          description: 'User UUID or member ref ("U12"); bare numbers never resolve.',
        }
    get:
      operationId: getAccountUser
      tags: [account_users]
      responses:
        "200":
          description: Account user
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/AccountUser" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateAccountUser
      description: >
        Human team owners/admins only (403 otherwise; an admin can't change an owner
        or admin, or make anyone owner). 409 when it would demote the team's last owner.
      tags: [account_users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account_user]
              properties:
                account_user:
                  type: object
                  properties: { role: { type: string } }
      responses:
        "200":
          description: Updated account user
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/AccountUser" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: removeAccountUser
      tags: [account_users]
      summary: Remove a member from the account (soft-leave).
      description: >
        Sets the member's membership_status to "left" (the row is kept so the
        person still resolves by name in projects, shown as "已离开"). Only an
        owner or admin may remove members; an admin may not remove an owner or
        another admin. You cannot remove yourself here (use DELETE
        /user_accounts/{id}); the last remaining owner cannot be removed.
      responses:
        "200":
          description: Member soft-left; returns the updated account user.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/AccountUser" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /api/v1/accounts/{account_id}/users/{id}/convert_to_agent:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - {
          name: id,
          in: path,
          required: true,
          schema: { type: string },
          description: 'User UUID or member ref ("U12"); bare numbers never resolve.',
        }
    post:
      operationId: convertAccountUserToAgent
      tags: [agents, account_users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent]
              properties:
                agent:
                  type: object
                  required: [managed_by_id]
                  properties:
                    managed_by_id: { type: string, format: uuid }
                    confirm_admin: { type: boolean }
      responses:
        "200":
          description: Converted agent member
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/AccountUser" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  # ---- project task_types (the link rows), email_forward, config, invitation
  /api/v1/accounts/{account_id}/projects/{project_id}/task_types:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listProjectTaskTypes
      tags: [project_task_types]
      responses:
        "200":
          description: Project task type links
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items:
                              { $ref: "#/components/schemas/ProjectTaskType" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createProjectTaskType
      description: Project admins and team owners/admins only; others get 403.
      tags: [project_task_types]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_task_type]
              properties:
                project_task_type:
                  type: object
                  properties: { task_type_id: { type: string, format: uuid } }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Existing link
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/ProjectTaskType" } }
        "201":
          description: Created link
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/ProjectTaskType" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
  /api/v1/accounts/{account_id}/projects/{project_id}/task_types/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getProjectTaskType
      tags: [project_task_types]
      responses:
        "200":
          description: Link
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/ProjectTaskType" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateProjectTaskType
      description: Project admins and team owners/admins only; others get 403.
      tags: [project_task_types]
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    properties: { project_task_type: { type: object } },
                  },
              },
          }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated link
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/ProjectTaskType" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteProjectTaskType
      description: Project admins and team owners/admins only; others get 403.
      tags: [project_task_types]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/projects/{project_id}/email_forward:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: getEmailForward
      tags: [email_forward]
      responses:
        "200":
          description: Email forward
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/EmailForward" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      operationId: updateEmailForward
      description: Project admins and team owners/admins only; others get 403.
      tags: [email_forward]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                regenerate: { type: boolean }
                email_forward:
                  type: object
                  properties: { status: { type: string } }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated email forward
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/EmailForward" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/projects/{project_id}/config:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: getProjectConfig
      tags: [project_config]
      responses:
        "200":
          description: Project config
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/ProjectConfig" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      operationId: updateProjectConfig
      description: Project admins and team owners/admins only; others get 403.
      tags: [project_config]
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    properties: { project_config: { type: object } },
                  },
              },
          }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated project config
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/ProjectConfig" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
  /api/v1/accounts/{account_id}/projects/{project_id}/invitation:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: getProjectInvitation
      summary: Get (or create) the project's reusable invite link
      description: Project admins and team owners/admins only; others get 403.
      tags: [invitations]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Project invitation
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Invitation" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
    delete:
      operationId: deleteProjectInvitation
      summary: Revoke the project's invite link
      description: Project admins and team owners/admins only; others get 403.
      tags: [invitations]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/projects/{project_id}/forwarded_emails:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listForwardedEmails
      tags: [forwarded_emails]
      parameters:
        - $ref: "#/components/parameters/ListContent"
      responses:
        "200":
          description: Forwarded emails
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items:
                              { $ref: "#/components/schemas/ForwardedEmail" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
  # Account-scoped show: canonical (project-less) URLs load an email thread
  # by ref; list/destroy remain project-scoped below.
  /api/v1/accounts/{account_id}/forwarded_emails/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/RecordingRef" },
        }
    get:
      operationId: getAccountForwardedEmail
      tags: [forwarded_emails]
      responses:
        "200":
          description: Forwarded email
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/ForwardedEmail" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/projects/{project_id}/forwarded_emails/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/RecordingRef" },
        }
    get:
      operationId: getForwardedEmail
      tags: [forwarded_emails]
      responses:
        "200":
          description: Forwarded email
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/ForwardedEmail" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteForwardedEmail
      summary: Soft-delete a forwarded email (returns the trashed recording)
      tags: [forwarded_emails]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Trashed forwarded email
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/ForwardedEmail" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- invitations (public show) + rsvps ------------------------------------
  /api/v1/invitations/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getInvitation
      summary: Look up an invitation by token
      tags: [invitations]
      responses:
        "200":
          description: Invitation
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Invitation" } }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/invitations/{invitation_id}/rsvps:
    parameters:
      - {
          name: invitation_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    post:
      operationId: createRsvp
      summary: Accept an invitation (join the account + project)
      description: Requires a signed-in human session; API keys get 403.
      tags: [invitations]
      responses:
        "201":
          description: Accepted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- project tools (index/show/update/destroy; no create route) -----------
  /api/v1/accounts/{account_id}/projects/{project_id}/tools:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listTools
      tags: [tools]
      responses:
        "200":
          description: Tools
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Tool" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/projects/{project_id}/tools/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getTool
      tags: [tools]
      responses:
        "200":
          description: Tool
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Tool" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateTool
      description: Project admins and team owners/admins only; others get 403.
      tags: [tools]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tool]
              properties:
                tool:
                  type: object
                  properties:
                    name: { type: string }
                    slug: { type: string }
                    description: { type: string }
                    status: { type: string, nullable: true }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated tool
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Tool" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: deleteTool
      description: Project admins and team owners/admins only; others get 403.
      summary: Disable a tool (sets status null; returns the tool)
      tags: [tools]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Disabled tool
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Tool" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- project versions (CRUD) ----------------------------------------------
  /api/v1/accounts/{account_id}/projects/{project_id}/versions:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listVersions
      tags: [versions]
      responses:
        "200":
          description: Versions
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Version" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createVersion
      tags: [versions]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [version]
              properties:
                version:
                  type: object
                  properties:
                    name: { type: string }
                    status: { type: string }
                    description: { type: string }
                    start_date: { type: string }
                    effective_date: { type: string }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created version
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Version" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
  /api/v1/accounts/{account_id}/projects/{project_id}/versions/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getVersion
      tags: [versions]
      responses:
        "200":
          description: Version
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Version" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateVersion
      tags: [versions]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [version]
              properties:
                version:
                  type: object
                  description: Only the fields sent change.
                  properties:
                    name: { type: string }
                    status: { type: string, enum: [active, archived, trashed] }
                    description: { type: string, nullable: true }
                    start_date: { type: string, format: date, nullable: true }
                    effective_date: { type: string, format: date, nullable: true }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated version
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Version" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: deleteVersion
      tags: [versions]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- quick_accesses (index/create/destroy) --------------------------------
  /api/v1/accounts/{account_id}/quick_accesses:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listQuickAccesses
      tags: [quick_accesses]
      responses:
        "200":
          description: Quick accesses
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/QuickAccess" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createQuickAccess
      tags: [quick_accesses]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [quick_access]
              properties:
                quick_access:
                  type: object
                  properties: { project_id: { type: string, format: uuid } }
      responses:
        "201":
          description: Created quick access
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/QuickAccess" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
  /api/v1/accounts/{account_id}/quick_accesses/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      operationId: deleteQuickAccess
      summary: Remove a quick access (id is the project_id)
      tags: [quick_accesses]
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties: { project_id: { type: string, format: uuid } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- members (account index + project CRUD) -------------------------------
  /api/v1/accounts/{account_id}/members:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listAccountMembers
      tags: [members]
      responses:
        "200":
          description: Members across the account's projects
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Member" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/projects/{project_id}/members:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listProjectMembers
      tags: [members]
      responses:
        "200":
          description: Project members
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Member" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: createProjectMember
      description: Project admins and team owners/admins only (403 otherwise). user_id must be an active or paused member of the team (422 otherwise).
      tags: [members]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [member]
              properties:
                member:
                  type: object
                  properties:
                    { user_id: { type: string, format: uuid }, role: { type: string } }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "201":
          description: Created member
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Member" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
  /api/v1/accounts/{account_id}/projects/{project_id}/members/{id}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getProjectMember
      tags: [members]
      responses:
        "200":
          description: Member
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Member" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateProjectMember
      description: >-
        Changes the member's project role (`admin` 管理员, `member` 成员,
        `reader` 仅查看). Project admins and team owners/admins only; others
        get 403. Granting or removing `admin` is for people: an agent gets 403
        whatever its role. Agents can't be made project admins (403), and
        demoting the project's last admin who is still in the team is 409.
      tags: [members]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [member]
              properties:
                member:
                  type: object
                  properties:
                    role: { type: string, enum: [admin, member, reader] }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "200":
          description: Updated member
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Member" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: deleteProjectMember
      description: >-
        Project admins and team owners/admins only; others get 403. Removing
        the project's last admin who is still in the team is 409.
      tags: [members]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- device tokens (push) -------------------------------------------------
  /api/v1/device_tokens:
    post:
      operationId: createDeviceToken
      tags: [device_tokens]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
                platform: { type: string }
      responses:
        "200":
          description: Registered
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/device_tokens/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    delete:
      operationId: deleteDeviceToken
      tags: [device_tokens]
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ---- attachments (create returns the access URLs) -------------------------
  # NOTE: GET /attachments/:id[/raw|/preview] and /user_avatars/:id are 302
  # redirects to signed blob URLs — these are intentionally exempt from the
  # {data} envelope. The browser-only ActiveStorage direct-upload handshake is
  # session-authenticated and deliberately outside this contract.
  /api/v1/accounts/{account_id}/attachments:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    post:
      operationId: createAttachment
      tags: [attachments]
      requestBody:
        required: true
        # API clients upload the file itself as multipart/form-data in one
        # request. (The JSON signed_id form is the webapp's browser flow —
        # it sends the blob signed_id minted by its session-authenticated
        # ActiveStorage direct upload; API clients cannot mint one.)
        # Size caps: 20MB per file, 100MB for video/* content types.
        content:
          multipart/form-data:
            schema:
              type: object
              required: [attachment]
              properties:
                attachment:
                  type: object
                  required: [file]
                  properties: { file: { type: string, format: binary } }
          application/json:
            schema:
              type: object
              required: [attachment]
              properties:
                attachment:
                  type: object
                  required: [file]
                  properties:
                    file:
                      type: string
                      description: ActiveStorage blob signed_id (webapp browser flow only)
      responses:
        "201":
          description: Created attachment (access URLs and the embeddable card HTML)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Attachment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  # ---- current user ---------------------------------------------------------
  /api/v1/user:
    get:
      operationId: getCurrentUser
      tags: [user]
      responses:
        "200":
          description: Current user
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/User" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      operationId: updateCurrentUser
      tags: [user]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user]
              properties:
                user:
                  type: object
                  properties:
                    name: { type: string }
                    tagline: { type: string }
                    push_enabled: { type: boolean }
                    avatar:
                      type: string
                      nullable: true
                      description: Active Storage signed blob id of an uploaded image (browser direct upload); null removes the avatar.
      responses:
        "200":
          description: Updated user
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/User" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  # ---- accounts (top-level) -------------------------------------------------
  /api/v1/accounts:
    get:
      operationId: listAccounts
      description: |
        The caller's active teams, most recently joined first (the same
        order the OAuth consent page lists teams in). With an API key, only
        the key's own account is listed.
      tags: [accounts]
      responses:
        "200":
          description: Accounts the current user belongs to
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Account" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createAccount
      description: >-
        Signed-in sessions only (subject to team-creation eligibility); an API
        key cannot create a team and gets 403.
      tags: [accounts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account]
              properties:
                account:
                  type: object
                  properties: { name: { type: string } }
      responses:
        "201":
          description: Created account
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Account" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
  /api/v1/accounts/deleted:
    get:
      operationId: listDeletedAccounts
      description: >-
        Teams the signed-in human owns that are scheduled for deletion and can
        still be restored (`POST /accounts/{id}/restore`), most recently
        deleted first. Empty for API keys and OAuth tokens.
      tags: [accounts]
      responses:
        "200":
          description: Restorable deleted teams
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/DeletedAccount" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{id}/restore:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: restoreAccount
      description: >-
        Undoes a team deletion within its grace period (before `purge_at`).
        Any human owner of the team, from a signed-in session. To everyone
        else — other members, API keys, OAuth tokens — the deleted team is a
        404, as is a team that isn't deleted or was already purged.
      tags: [accounts]
      responses:
        "200":
          description: Restored account
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Account" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getAccount
      tags: [accounts]
      responses:
        "200":
          description: Account
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Account" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateAccount
      description: Active human team owners/admins only; others get 403, agents included whatever their role.
      tags: [accounts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account]
              properties:
                account:
                  type: object
                  properties: { name: { type: string } }
      responses:
        "200":
          description: Updated account
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/Account" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }
    delete:
      operationId: deleteAccount
      description: >-
        Schedules the team for deletion. Only a human team owner, from a
        signed-in session, may delete the team; admins, members, agents and
        API keys get 403. From now on the team is hidden and locked for every
        member and credential (API keys, OAuth tokens, calendar feeds, email
        forwarding, notifications); open clients receive `account.deleted` on
        the account channel. Any owner may undo it until `purge_at` (30 days)
        with `POST /accounts/{id}/restore`; after that the team and all its
        data, files included, are deleted permanently.
      tags: [accounts]
      responses:
        "200":
          description: Scheduled for deletion
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      { data: { $ref: "#/components/schemas/DeletedAccount" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- search + suggestion (custom shapes) ----------------------------------
  /api/v1/accounts/{account_id}/search:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: search
      summary: Full-text search across recordings (paginated; Meilisearch results)
      description: >-
        Only active recordings in projects the caller can access within this
        account are searched; `meta.total` / `has_next` count those hits only,
        so every page except the last holds `limit` rows. A task reference
        (`#1042`, `T1042`, `#T1042`) matches that task exactly.
      tags: [search]
      parameters:
        - { name: q, in: query, required: false, schema: { type: string } }
        - { name: page, in: query, required: false, schema: { type: integer } }
        - { name: limit, in: query, required: false, schema: { type: integer } }
      responses:
        "200":
          description: Search results
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/SearchHit" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/search/suggestion:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: searchSuggestion
      summary: Typeahead suggestions (users/documents/tasks/messages)
      tags: [search]
      parameters:
        - {
            name: project_id,
            in: query,
            required: false,
            schema: { type: string },
          }
        - { name: q, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: Suggestions
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [users, documents, tasks, messages]
                        properties:
                          users:
                            type: array
                            description: >-
                              Current members only (active, then paused; never members who
                              left), up to 10.
                            items: { $ref: "#/components/schemas/MemberSuggestion" }
                          documents: { type: array, items: { $ref: "#/components/schemas/RecordingSummary" } }
                          tasks: { type: array, items: { $ref: "#/components/schemas/RecordingSummary" } }
                          messages: { type: array, items: { $ref: "#/components/schemas/RecordingSummary" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---- activities (paginated; same view across 5 mount points) --------------
  /api/v1/accounts/{account_id}/activities:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listActivities
      tags: [activities]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/ActivityLimit"
      responses:
        "200":
          description: Activity page
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Activity" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/projects/{project_id}/activities:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: project_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listProjectActivities
      tags: [activities]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/ActivityLimit"
      responses:
        "200":
          description: Activity page
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Activity" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/recordings/{recording_id}/activities:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
    get:
      operationId: listRecordingActivities
      tags: [activities]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/ActivityLimit"
      responses:
        "200":
          description: Activity page
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Activity" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/users/{user_id}/activities:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - {
          name: user_id,
          in: path,
          required: true,
          schema: { type: string },
          description: 'User UUID or member ref ("U12"); bare numbers never resolve.',
        }
    get:
      operationId: listUserActivities
      summary: A member's activity in projects the caller can read
      description: >-
        Lists the member's activity, newest first, limited to projects the
        caller can currently read (the same rule as the account activity
        feed). Activity in other projects is omitted, not masked.
      tags: [activities]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/ActivityLimit"
      responses:
        "200":
          description: Activity page
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Activity" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/my/activities:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listMyActivities
      tags: [activities]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/ActivityLimit"
      responses:
        "200":
          description: Activity page
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Activity" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ---- my/tasks, drafts, bookmarks (list endpoints) -------------------------
  /api/v1/accounts/{account_id}/users/{user_id}/tasks:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - {
          name: user_id,
          in: path,
          required: true,
          schema: { type: string },
          description: 'User UUID or member ref ("U12").',
        }
    get:
      operationId: listMemberTasks
      summary: A member's open tasks, as far as the caller can read them
      description: |
        The same list as GET .../my/tasks for any member (open = backlog, todo or
        in-progress stage), limited to projects the caller can read; paginated, default
        limit 50, newest first. meta.hidden_count is the number of that member's open
        tasks in projects the caller cannot read (shown as "N more in projects you
        cannot access", never listed).
      tags: [tasks]
      parameters:
        - $ref: "#/components/parameters/ListContent"
        - { name: page, in: query, required: false, schema: { type: integer } }
        - { name: limit, in: query, required: false, schema: { type: integer } }
      responses:
        "200":
          description: Open tasks (paginated) with hidden_count
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                additionalProperties: false
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Task" }
                  meta:
                    allOf:
                      - $ref: "#/components/schemas/PaginationMeta"
                      - type: object
                        required: [hidden_count]
                        properties:
                          hidden_count: { type: integer, minimum: 0 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/my/tasks:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listMyTasks
      tags: [tasks]
      parameters:
        - $ref: "#/components/parameters/ListContent"
      responses:
        "200":
          description: Assigned, unfinished tasks (paginated)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Task" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/drafts:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listDrafts
      summary: The current user's draft recordings (not paginated)
      tags: [drafts]
      parameters:
        - $ref: "#/components/parameters/ListContent"
      responses:
        "200":
          description: Draft recordings
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Recording" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/accounts/{account_id}/bookmarks:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    get:
      operationId: listBookmarks
      summary: The current user's bookmarks (not paginated)
      tags: [bookmarks]
      responses:
        "200":
          description: Bookmarks
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      {
                        data:
                          {
                            type: array,
                            items: { $ref: "#/components/schemas/Bookmark" },
                          },
                      }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ---- task moves (reorder) -------------------------------------------------
  # Move is a board-level operation on task-level fields (rank/task_status_id),
  # addressed by recording.id (NOT public_id). Top-level resource, decoupled
  # from the public_id-keyed /tasks/:id namespace. See api-conventions §2.
  /api/v1/accounts/{account_id}/task_moves:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    post:
      operationId: createTaskMove
      summary: Reorder / restatus a task (returns the updated task)
      description: |
        The task must be accessible and its neighbors must belong to its
        project; an unknown task or neighbor returns 404. task_status_id must
        be a status of the task's type, as on PATCH /tasks/{id}: any other
        status (another type's, or another team's) fails with 422
        validation_error (field task_status_id) and the task is left as it
        was. A task_status_id equal to the task's current status is not a
        status change and is not checked, and a move without task_status_id
        only reorders.
      tags: [tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [move]
              properties:
                move:
                  type: object
                  required: [task_id]
                  properties:
                    task_id: { type: string, format: uuid } # recording.id of the moved task
                    prev: { type: string, format: uuid, nullable: true } # recording.id of the previous neighbor
                    next: { type: string, format: uuid, nullable: true } # recording.id of the next neighbor
                    task_status_id: { type: string, format: uuid } # a status of the task's type; omit to only reorder
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated task
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties: { data: { $ref: "#/components/schemas/Task" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  # ---- recording state-change actions (legacy verb paths; conventions §9) ---
  /api/v1/accounts/{account_id}/recordings/{recording_id}/status/archived:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
    put:
      operationId: archiveRecording
      tags: [recordings]
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Archived recording
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                          anyOf:
                            - $ref: "#/components/schemas/DriveItem"
                            - $ref: "#/components/schemas/Recording"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/recordings/{recording_id}/status/trashed:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
    put:
      operationId: trashRecording
      tags: [recordings]
      description: >-
        Deletes the recording into its project's recycle bin (回收站, GET
        /projects/{project_id}/trashed_recordings): restorable for 30 days,
        then purged for good.
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Trashed recording
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                          anyOf:
                            - $ref: "#/components/schemas/DriveItem"
                            - $ref: "#/components/schemas/Recording"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/recordings/{recording_id}/status/active:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
    put:
      operationId: activateRecording
      tags: [recordings]
      description: >-
        Unarchives, publishes a draft, or restores a deleted recording. For a
        deleted task, discussion, document, file, folder, schedule, goal or
        forwarded email this is the recycle bin's restore (see
        restoreTrashedRecording): it returns to the status it had — which
        may be archived or draft — under the bin's permission rules.
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Activated recording
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                          anyOf:
                            - $ref: "#/components/schemas/DriveItem"
                            - $ref: "#/components/schemas/Recording"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/recordings/{recording_id}/transfer:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
    patch:
      operationId: transferRecording
      tags: [recordings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_project_id]
              properties:
                target_project_id: { type: string, format: uuid }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Transferred recording
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                          anyOf:
                            - $ref: "#/components/schemas/DriveItem"
                            - $ref: "#/components/schemas/Recording"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/recordings/{recording_id}/revisions:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
    get:
      operationId: listRecordingRevisions
      tags: [recordings]
      summary: Revision history (历史版本) of a recording, newest first
      description: >-
        Documents and discussions keep their title and body, tasks their
        description; other types have no history (empty list). Metadata
        only — read one revision's body with GET .../revisions/{number}.
        Readable by whoever can read the recording: trashed recordings 404,
        drafts 404 for everyone but their author.
      parameters:
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: limit, in: query, description: "1–100 (400 param_invalid otherwise).", schema: { type: integer, default: 50 } }
      responses:
        "200":
          description: Revision page
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CollectionEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/RecordingRevision" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/recordings/{recording_id}/revisions/{number}:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
      - $ref: "#/components/parameters/RevisionNumberParam"
    get:
      operationId: getRecordingRevision
      tags: [recordings]
      summary: One revision with its body
      responses:
        "200":
          description: Revision
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/RecordingRevisionDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/accounts/{account_id}/recordings/{recording_id}/revisions/{number}/restoration:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
      - $ref: "#/components/parameters/RecordingRefParam"
      - $ref: "#/components/parameters/RevisionNumberParam"
    post:
      operationId: restoreRecordingRevision
      tags: [recordings]
      summary: Restore a revision's body (signed-in members only, not API keys)
      description: >-
        Writes the revision's body back as the current body; the title stays
        as it is. content_version is the body version the restorer saw:
        stale → 409 content_conflict, nothing written. Adds a "restore"
        revision and a revision_restored activity (details.revision_number;
        none for drafts); sends no notifications and registers no mentions.
        When the revision already is the current body nothing is written and
        content_version stays the same. Needs project write access, and for
        a discussion its author (or, for an agent's discussion, its human
        manager or a team owner/admin). API keys (agents) are refused with
        403 — restore is for signed-in members.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content_version]
              properties:
                content_version: { $ref: "#/components/schemas/ContentVersion" }
      responses:
        "200":
          description: The recording after the restore
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        anyOf:
                          - $ref: "#/components/schemas/Task"
                          - $ref: "#/components/schemas/Recording"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/ContentConflict" }
  /api/v1/accounts/{account_id}/recordings/bulk_transfer:
    parameters:
      - {
          name: account_id,
          in: path,
          required: true,
          schema: { type: string },
        }
    post:
      operationId: bulkTransferRecordings
      summary: Transfer many recordings to a project (legacy verb path)
      tags: [recordings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recording_ids, target_project_id]
              properties:
                recording_ids: { type: array, items: { type: string, format: uuid } }
                target_project_id: { type: string, format: uuid }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Done (no content payload)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/accounts/{account_id}/projects/{id}/update_task_types:
    parameters:
      - name: account_id
        in: path
        required: true
        schema: { type: string }
      - name: id
        in: path
        required: true
        schema: { type: string }
    put:
      operationId: updateProjectTaskTypes
      description: Project admins and team owners/admins only; others get 403.
      summary: Replace the project's enabled task types (legacy verb path; see api-conventions §9)
      tags: [projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_type_ids]
              properties:
                task_type_ids:
                  type: array
                  items: { type: string, format: uuid }
      responses:
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Updated project
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/accounts/{account_id}/plans:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listPlans
      summary: Public, sellable plan catalog (excludes unlisted tiers)
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      tags: [billing]
      responses:
        "200":
          description: Plan catalog
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Plan" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/accounts/{account_id}/orders:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
    post:
      operationId: selectPlan
      summary: Select a plan — creates a pending order (manual settlement)
      description: Human team owners/admins only; others get 403, agents included whatever their role.
      tags: [billing]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order]
              properties:
                order:
                  type: object
                  required: [plan_code]
                  properties:
                    plan_code: { type: string, example: pro_yearly }
                    seats: { type: integer, minimum: 1, description: per_seat 套餐必填 }
      responses:
        "201":
          description: Pending order created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Order" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  /api/v1/accounts/{account_id}/invitations:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listAccountInvitations
      tags: [invitations]
      summary: List pending email invitations (human workspace owner/admin only)
      description: Invitation tokens are never returned here (`token` is null).
      responses:
        "200":
          description: Pending invitations
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Invitation" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createAccountInvitation
      tags: [invitations]
      summary: Send a workspace email invitation (human workspace owner/admin only)
      description: >-
        The invitation link is emailed to the recipient; the response carries
        `token: null`. Agents (API keys) cannot send team invitations.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [invitation]
              properties:
                invitation:
                  type: object
                  required: [email]
                  properties:
                    email: { type: string, description: Recipient email address; malformed addresses return 422 }
      responses:
        "201":
          description: Invitation queued for email delivery
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/DataEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Invitation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableContent" }

  /api/v1/accounts/{account_id}/invitations/{id}:
    parameters:
      - { name: account_id, in: path, required: true, schema: { type: string } }
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      operationId: revokeAccountInvitation
      tags: [invitations]
      responses:
        "200":
          description: Invitation revoked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/attachments/{id}:
    parameters:
      - { name: id, in: path, required: true, description: Attachment capability token, schema: { type: string } }
    get:
      operationId: downloadEditorAttachment
      tags: [attachments]
      security: []
      description: Anyone holding the capability token can download this editor attachment.
      responses:
        "302":
          description: Redirect to storage
          headers:
            Location: { schema: { type: string } }
        "404": { description: Unknown capability token }

  /api/v1/attachments/{id}/raw:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: readRawEditorAttachment
      tags: [attachments]
      security: []
      responses:
        "302":
          description: Redirect to raw file storage; requires the attachment capability token
          headers:
            Location: { schema: { type: string } }
        "404": { description: Unknown capability token }

  /api/v1/attachments/{id}/preview:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: previewEditorAttachment
      tags: [attachments]
      security: []
      responses:
        "302":
          description: Redirect to the preview; requires the attachment capability token
          headers:
            Location: { schema: { type: string } }
        "404": { description: Unknown capability token }

  /api/v1/user_avatars/{id}:
    parameters:
      - { name: id, in: path, required: true, description: Global numeric user avatar ID, schema: { type: string } }
    get:
      operationId: readUserAvatar
      tags: [users]
      security: []
      parameters:
        - { name: v, in: query, schema: { type: string }, description: Attachment version for long-lived caching }
      responses:
        "302":
          description: Redirect to the public avatar
          headers:
            Location: { schema: { type: string } }
        "404": { description: Unknown avatar user ID }

  # ===========================================================
  # OAuth for MCP clients (docs/api-conventions.md §12). The protocol
  # endpoints are rodauth-oauth's (app/misc/rodauth_oauth_config.rb) and
  # answer in RFC shapes, not {data}; errors are OAuthError, not RFC 9457:
  # /.well-known/oauth-protected-resource/mcp (RFC 9728),
  # /.well-known/oauth-authorization-server (RFC 8414), /auth_api/register
  # (RFC 7591), /auth_api/authorize (the consent page's JSON API),
  # /auth_api/token (RFC 6749 / 7636), /auth_api/revoke (RFC 7009).
  # ===========================================================
  /.well-known/oauth-protected-resource/mcp:
    get:
      operationId: getMcpProtectedResourceMetadata
      tags: [oauth]
      security: []
      summary: MCP server's protected resource metadata (RFC 9728)
      description: |
        Where an MCP client starts: a 401 from `https://qunli.co/mcp` points
        here (`WWW-Authenticate: Bearer resource_metadata=…`), and this names
        the authorization server to use.
      responses:
        "200":
          description: Protected resource metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthProtectedResourceMetadata" }

  /.well-known/oauth-authorization-server:
    get:
      operationId: getOauthAuthorizationServerMetadata
      tags: [oauth]
      security: []
      summary: Authorization server metadata (RFC 8414)
      description: |
        The issuer is `https://qunli.co` (no path), so the document is at the
        root. `authorization_endpoint` is the consent web page
        (`https://qunli.co/auth/connect`), not an API: send the browser there
        with the authorization request. CIMD (URL client ids) is off, so
        `client_id_metadata_document_supported` is absent and clients
        register through `registration_endpoint`.
      responses:
        "200":
          description: Authorization server metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthAuthorizationServerMetadata" }

  /auth_api/register:
    post:
      operationId: registerOauthClient
      tags: [oauth]
      security: []
      summary: Register an OAuth client (dynamic client registration, RFC 7591)
      description: |
        Open to anyone (MCP clients register on first connect), limited to
        30 registrations per IP per hour (429 `rate_limited`). `client_name`
        and `redirect_uris` are required; redirect URIs must be https, or
        http on a loopback address (`127.0.0.1`, `[::1]`, `localhost`, any
        port), without a fragment. Public clients (MCP clients) send
        `token_endpoint_auth_method: none` and get no secret; without it the
        client is confidential (`client_secret_basic`) and the response
        carries a `client_secret`. The only scope is `mcp`. Fields the server
        doesn't store are dropped and left out of the response.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OAuthClientRegistrationRequest" }
      responses:
        "201":
          description: The registered client.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthClientRegistration" }
        "400": { $ref: "#/components/responses/OAuthError" }
        "429": { $ref: "#/components/responses/OAuthError" }

  /auth_api/authorize:
    get:
      operationId: getOauthAuthorization
      tags: [oauth]
      security: [{ sessionCookie: [] }]
      description: |
        Validates an authorization request and returns what the consent page
        shows. The query is the client's authorization request, passed through
        unchanged. Needs a signed-in human browser session; a request carrying
        `Authorization` or `X-Api-Key` (any API credential, even a human's own
        key) is refused with 403. Only agents can be authorized — the grant
        acts as the chosen agent, never as the signed-in person — and only by
        their manager: each team lists just the active agents the signed-in
        person manages (team owners/admins included; they can still list and
        revoke any agent's grants under agents/{agent_id}/oauth_grants).
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string }, description: "DCR client id. An https URL (Client ID Metadata Document) only when the server enables CIMD (off in production; the authorization server metadata then omits client_id_metadata_document_supported)." }
        - { name: redirect_uri, in: query, required: false, schema: { type: string } }
        - { name: response_type, in: query, required: true, schema: { type: string, enum: [code] } }
        - { name: state, in: query, required: false, schema: { type: string } }
        - { name: code_challenge, in: query, required: true, schema: { type: string } }
        - { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] } }
        - { name: resource, in: query, required: true, schema: { type: string }, description: "Must be the MCP server, https://qunli.co/mcp (RFC 8707)." }
        - { name: scope, in: query, required: false, schema: { type: string, example: mcp } }
      responses:
        "200":
          description: The client and the agents the signed-in human may authorize.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthConsent" }
        "400": { $ref: "#/components/responses/OAuthError" }
        "401": { $ref: "#/components/responses/OAuthError" }
        "403": { $ref: "#/components/responses/OAuthError" }
    post:
      operationId: decideOauthAuthorization
      tags: [oauth]
      security: [{ sessionCookie: [] }]
      description: |
        Approves or denies the authorization request. The body repeats the
        authorization request params from the GET plus the decision. Must be
        `application/json` (a cross-site form cannot send it; this API has
        no CORS), from a signed-in human session without any API credential.
        Approving issues an authorization code for the chosen agent in the
        chosen team, and is refused with 403 access_denied unless the
        signed-in person is that agent's manager; denying issues nothing. Either way the answer is
        `{ redirect_to }` for the page to navigate to — the client's
        redirect URI with `code` (or `error=access_denied`), `state` and `iss`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OAuthAuthorizationDecision" }
      responses:
        "200":
          description: Where to send the browser.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthRedirect" }
        "400": { $ref: "#/components/responses/OAuthError" }
        "401": { $ref: "#/components/responses/OAuthError" }
        "403": { $ref: "#/components/responses/OAuthError" }
        "422": { $ref: "#/components/responses/OAuthError" }

  /auth_api/token:
    post:
      operationId: createOauthToken
      tags: [oauth]
      # Public clients authenticate with client_id in the body (no scheme).
      security: [{}, { oauthClientBasic: [] }]
      summary: Exchange an authorization code or a refresh token for tokens (RFC 6749, PKCE RFC 7636)
      description: |
        Form-encoded. Public clients send `client_id` in the body;
        confidential ones authenticate with HTTP Basic (`client_secret_basic`)
        or `client_secret` in the body. `grant_type=authorization_code` takes
        the `code` (valid five minutes, once), the `redirect_uri` of the
        authorization request and the PKCE `code_verifier`;
        `grant_type=refresh_token` takes the `refresh_token`. `resource`, when
        sent, must be `https://qunli.co/mcp`.

        Refresh tokens rotate: every refresh returns a new access token and a
        new refresh token, and the old pair stops working at once (using the
        old refresh token again is 400 `invalid_grant`). Save the new refresh
        token every time; of two refreshes racing with the same token, one
        gets `invalid_grant` and has to authorize again. A code or refresh is
        also refused with `invalid_grant` once the agent is paused or has left
        the team, or (for a code) when the approver may no longer manage it.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/OAuthTokenRequest" }
      responses:
        "200":
          description: The tokens.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthTokenResponse" }
        "400": { $ref: "#/components/responses/OAuthError" }
        "401":
          description: Unknown client or failed client authentication (`invalid_client`).
          headers:
            WWW-Authenticate: { schema: { type: string, example: Bearer } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

  /auth_api/revoke:
    post:
      operationId: revokeOauthToken
      tags: [oauth]
      # Public clients authenticate with client_id in the body (no scheme).
      security: [{}, { oauthClientBasic: [] }]
      summary: Revoke an access or refresh token (RFC 7009)
      description: |
        Form-encoded; the client authenticates as at the token endpoint.
        Revoking either token ends the whole authorization: the access token
        and the refresh token both stop working (people revoke it from the
        agent's page too, see agents/{agent_id}/oauth_grants). A token that
        is unknown, malformed or missing is 400 `invalid_request`.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/OAuthRevocationRequest" }
      responses:
        "200":
          description: Revoked. Only the status matters; the body echoes the revocation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthRevocation" }
        "400": { $ref: "#/components/responses/OAuthError" }
        "401":
          description: Unknown client or failed client authentication (`invalid_client`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

components:
  parameters:
    PageParam:
      name: page
      in: query
      description: 1-based page number (meta.page / meta.has_next say where you are).
      schema: { type: integer, minimum: 1, default: 1 }
    ActivityLimit:
      name: limit
      in: query
      description: Rows per page (default 100; 50 for my/activities).
      schema: { type: integer, minimum: 1 }
    # Body mode of recording lists (docs/api-conventions.md §9). full (the
    # default today) keeps every row's content_html / content_json /
    # content_markdown / content_version; summary leaves those keys out and
    # rows carry only preview_text / excerpt (and preview_html for
    # documents). Fetch GET .../recordings/:ref (or the typed show endpoint)
    # for the body.
    ListContent:
      name: content
      in: query
      description: >-
        full (default): rows carry their whole body. summary: rows leave out
        content_html, content_json, content_markdown and content_version and
        carry only preview_text / excerpt (documents: also preview_html);
        read one record for its body. Other values → 400 param_invalid.
      schema: { type: string, enum: [full, summary], default: full }
    CalendarMembers:
      name: member_ids
      in: query
      description: "Required for scope=members. 1–100 live account member refs (U12) or user UUIDs, comma-separated or array. OR across selected members; intersects project filters and caller access."
      schema:
        oneOf:
          - { type: string }
          - { type: array, items: { type: string } }
    # Recording address for sub-resource routes (comments/reactions/…): the
    # recording's typed public_id ("T1042", "M15761") — case-insensitive, a
    # leading "#" is tolerated, mismatches 404. Bare numbers remain global
    # recording-id lookups (legacy, deprecated for writes). On typed routes
    # (/tasks/:id, /messages/:id, …) a bare number is instead the historical
    # URL number, expanded with the route's own prefix, so pre-switch links
    # keep working. See docs/api-conventions.md §2.
    RecordingRefParam:
      name: recording_id
      in: path
      required: true
      schema: { $ref: "#/components/schemas/RecordingRef" }

    # Revision number within one recording's history (1, 2, …).
    RevisionNumberParam:
      name: number
      in: path
      required: true
      schema: { type: integer, minimum: 1 }

    # Schedule list window: a date (YYYY-MM-DD = 00:00 Asia/Shanghai) or an ISO
    # 8601 date-time with offset. Both or neither; to is exclusive; to - from
    # must be > 0 and ≤ 92 days (400 param_invalid otherwise).
    ScheduleWindowFrom:
      name: from
      in: query
      description: "Window start (inclusive). Date (YYYY-MM-DD) or ISO 8601 date-time; percent-encode a + offset as %2B (or use Z). Defaults with `to` to the current month."
      schema: { type: string, example: "2026-09-28" }
    ScheduleWindowTo:
      name: to
      in: query
      description: "Window end (exclusive). Date or date-time; percent-encode a + offset as %2B."
      schema: { type: string, example: "2026-11-02" }

  # ===========================================================
  # Success envelopes
  # ===========================================================
  schemas:
    UUID:
      type: string
      format: uuid

    PublicId:
      type: string
      pattern: '^\d+$'

    # A recording's typed public_id ("T1042", "M15761") or a bare number
    # (legacy / historical URL form). See parameters/RecordingRefParam.
    RecordingRef:
      type: string
      # Committee validates the raw path, before Rails decodes a leading #.
      pattern: '^(#|%23)?[A-Za-z]?\d+$'

    # A membership's per-account token ("U12"). Born typed — bare numbers
    # never resolve as member refs.
    MemberRef:
      type: string
      pattern: '^U\d+$'

    DataEnvelope:
      type: object
      required: [data]
      properties:
        data:
          description: |
            Resource payload (object), array of resources, or `null` for
            successful operations with no return value. Concrete shape
            is defined per-endpoint by inheriting and constraining `data`.
          nullable: true
      additionalProperties: false

    CollectionEnvelope:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: {}
        meta:
          $ref: "#/components/schemas/PaginationMeta"
      additionalProperties: false

    PaginationMeta:
      type: object
      required: [page, limit, total, total_pages, has_next]
      properties:
        page:
          type: integer
          minimum: 1
          example: 1
        limit:
          type: integer
          minimum: 1
          example: 30
        total:
          type: integer
          minimum: 0
          example: 142
        total_pages:
          type: integer
          minimum: 0
          example: 5
        has_next:
          type: boolean
          example: true

    # GET .../notifications?after=<cursor>
    NotificationSavedCursor:
      type: object
      required: [cursor]
      additionalProperties: false
      properties:
        cursor:
          type: string
          nullable: true
          description: Opaque; pass it as `after`. null when nothing was saved yet.
    NotificationCursorMeta:
      type: object
      required: [limit, has_more, next_cursor]
      additionalProperties: false
      properties:
        limit:
          type: integer
          minimum: 1
          maximum: 100
          example: 30
        has_more:
          type: boolean
          description: More notifications are ready after next_cursor; poll again right away.
        next_cursor:
          type: string
          description: Opaque; pass as `after` next time. Equals the sent cursor when nothing was new.

    # ===========================================================
    # Error envelope (RFC 9457 Problem Details)
    # ===========================================================
    ProblemDetails:
      type: object
      required: [type, title, status, detail, instance, code, trace_id]
      properties:
        type:
          type: string
          description: Relative URI identifying the error class.
          example: /errors/validation_error
        title:
          type: string
          description: Human-readable summary, constant per `type`.
          example: Validation failed
        status:
          type: integer
          minimum: 400
          maximum: 599
          example: 422
        detail:
          type: string
          description: Occurrence-specific explanation of this error.
          example: Task title can't be blank
        instance:
          type: string
          description: Request path that produced this error.
          example: /api/v1/accounts/1/tasks
        code:
          $ref: "#/components/schemas/ErrorCode"
        trace_id:
          type: string
          description: Request ID for log correlation.
          example: 01HXXXYYYYZZZZ
        errors:
          type: array
          description: Field-level details, present only for `validation_error`.
          items:
            $ref: "#/components/schemas/FieldError"

    FieldError:
      type: object
      required: [field, code, message]
      properties:
        field:
          type: string
          example: title
        code:
          type: string
          description: Stable identifier of the validator that failed.
          example: blank
        message:
          type: string
          example: "can't be blank"

    ApiKey:
      x-entity: apiKey
      type: object
      required: [id, name, token_prefix, last_used_at, revoked_at, created_at]
      properties:
        id:
          type: string
          format: uuid
          example: 1
        name:
          type: string
          example: ci-bot
        token_prefix:
          type: string
          description: First few characters of the token, safe to display.
          example: qk_abc123
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: Approximate last successful authentication time, updated at most once every five minutes.
        revoked_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time

    # Create response only. A projection, not an apiKey row: the plaintext
    # token must not outlive the dialog that shows it.
    ApiKeyWithToken:
      x-projection: true
      allOf:
        - $ref: "#/components/schemas/ApiKey"
        - type: object
          required: [token]
          properties:
            token:
              type: string
              description: |
                Full plaintext token. Returned only on the create response —
                store it now, it cannot be retrieved again.

    TaskType:
      x-entity: taskType
      type: object
      required:
        - id
        - name
        - color
        - description
        - tasks_count
        - position
        - is_project_default
        - is_milestone
        - account_id
        - account_public_id
        - task_status_ids
        - created_at
        - updated_at
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        color:
          type: string
        description:
          type: string
          nullable: true
        tasks_count:
          type: integer
          description: Tasks of this type, not counting trashed tasks or the tasks of deleted projects.
        position:
          type: integer
        is_project_default:
          type: boolean
        is_milestone:
          type: boolean
        account_id:
          type: string
          format: uuid
        account_public_id: { $ref: "#/components/schemas/PublicId" }
        task_status_ids:
          type: array
          items:
            type: string
            format: uuid
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    TaskTypeInput:
      type: object
      description: Writable attributes for create/update (wrapped under `task_type`).
      properties:
        name:
          type: string
        color:
          type: string
        description:
          type: string
          nullable: true
        is_project_default:
          type: boolean
        is_milestone:
          type: boolean

    TaskStatus:
      x-entity: taskStatus
      type: object
      required:
        - id
        - name
        - color
        - stage
        - position
        - tasks_count
        - account_id
        - account_public_id
        - created_at
        - updated_at
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        color: { type: string, nullable: true }
        stage:
          type: string
          enum: [backlog, todo, in_progress, completed, cancelled]
        position: { type: integer }
        tasks_count: { type: integer, description: "Tasks in this status, not counting trashed tasks or the tasks of deleted projects." }
        account_id: { type: string, format: uuid }
        account_public_id: { $ref: "#/components/schemas/PublicId" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TaskStatusInput:
      type: object
      properties:
        name: { type: string }
        color: { type: string, nullable: true }
        stage: { type: string }

    Priority:
      x-entity: priority
      type: object
      required:
        - id
        - name
        - position
        - is_default
        - color
        - account_id
        - account_public_id
        - created_at
        - updated_at
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        position: { type: integer }
        is_default: { type: boolean }
        color: { type: string, nullable: true }
        account_id: { type: string, format: uuid }
        account_public_id: { $ref: "#/components/schemas/PublicId" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PriorityInput:
      type: object
      properties:
        name: { type: string }
        position: { type: integer }
        is_default: { type: boolean }
        color: { type: string, nullable: true }

    ReactionType:
      x-entity: reactionType
      type: object
      required:
        - id
        - name
        - emoji
        - position
        - reactions_count
        - account_id
        - account_public_id
        - created_at
        - updated_at
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        emoji: { type: string }
        position: { type: integer }
        reactions_count: { type: integer }
        account_id: { type: string, format: uuid }
        account_public_id: { $ref: "#/components/schemas/PublicId" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ReactionTypeInput:
      type: object
      properties:
        name: { type: string }
        emoji: { type: string }
        position: { type: integer }

    Version:
      x-entity: version
      type: object
      required:
        [
          id,
          name,
          description,
          status,
          start_date,
          effective_date,
          project_id,
          created_at,
          updated_at,
        ]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        status: { type: string }
        start_date: { type: string, format: date, nullable: true }
        effective_date: { type: string, format: date, nullable: true }
        project_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Project:
      x-entity: project
      type: object
      required:
        - id
        - public_id
        - account_id
        - account_public_id
        - name
        - icon
        - color
        - visibility
        - project_type
        - parent_id
        - parent_public_id
        - description
        - stage
        - status
        - start_date
        - target_date
        - creator_id
        - created_at
        - updated_at
        - tools
        - members
        - versions
        - task_type_ids
        - cover_url
        - cover_position
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        public_id: { $ref: "#/components/schemas/PublicId" }
        account_id: { type: string, format: uuid }
        account_public_id: { $ref: "#/components/schemas/PublicId" }
        name: { type: string }
        # Gallery image URL, or the same-origin Active Storage path of an
        # uploaded cover. Null when the project has no cover.
        cover_url: { type: string, nullable: true }
        # Vertical focal point of the cover, 0 (top) to 100 (bottom).
        cover_position: { type: integer, minimum: 0, maximum: 100 }
        icon: { type: string, nullable: true }
        color: { type: string, nullable: true }
        visibility: { type: string, enum: [invite_only, team] }
        project_type: { type: string, enum: [project, team_space, headquarter] }
        parent_id: { type: string, format: uuid, nullable: true }
        parent_public_id: { type: string, nullable: true, pattern: '^\d+$' }
        description: { type: string, nullable: true }
        stage:
          {
            type: string,
            enum: [backlog, planning, in_progress, completed, cancelled],
          }
        status: { type: string, enum: [active, archived, trashed] }
        start_date: { type: string, format: date, nullable: true }
        target_date: { type: string, format: date, nullable: true }
        creator_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        tools:
          type: array
          items: { $ref: "#/components/schemas/Tool" }
        members:
          type: array
          items: { $ref: "#/components/schemas/Member" }
        versions:
          type: array
          items:
            $ref: "#/components/schemas/Version"
        task_type_ids:
          type: array
          description: "Task types enabled in this project, in the team's task type order. The first is the default type of a task created without task_type_id."
          items: { type: string, format: uuid }

    Reaction:
      x-entity: reaction
      type: object
      required:
        [
          id,
          actor_id,
          reactable_id,
          reactable_type,
          reaction_type_id,
          reaction_emoji,
          reaction_name,
          created_at,
          updated_at,
        ]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        actor_id: { type: string, format: uuid }
        reactable_id: { type: string, format: uuid }
        reactable_type: { type: string }
        reaction_type_id: { type: string, format: uuid }
        reaction_emoji: { type: string }
        reaction_name: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # Base shape shared by all recordings (messages, documents, tasks, goals).
    # NOTE: no additionalProperties:false — Task/Goal extend this flat shape
    # via allOf, adding recordable-specific fields at the top level.
    # Body keys (content_html, content_json, content_markdown,
    # content_version) are present on single records and on list rows unless
    # the list was requested with content=summary (ListContent).
    #
    # Entity annotations (read by the webapp's entity extractor and any other
    # normalizing client): every nested object that carries an `id` is a
    # named schema marked either `x-entity: <key>` (a row the client stores
    # under that key) or `x-projection: true` (a read-only snapshot that stays
    # inside its parent). Recordings share one id space and are keyed by
    # recordable_type through the map anchored here and reused by every
    # recording-shaped schema.
    Recording:
      type: object
      x-entity: &recordable
        by: recordable_type
        map:
          Task: task
          Message: message
          Document: document
          Comment: comment
          Goal: goal
          Schedule: schedule
          ForwardedEmail: forwardedEmail
          Upload: upload
          Folder: folder
      required:
        - id
        - legacy_id
        - title
        - comments_count
        - type
        - status
        - recordable_type
        - app_url
        - account_id
        - account_public_id
        - project_id
        - project_public_id
        - creator_id
        - created_at
        - updated_at
        - reactions
      properties:
        id: { type: string, format: uuid }
        # Typed per-account identifier ("T1042", "M15761", "C973"); the
        # canonical external address for the recording. Nullable only for
        # rows predating the typed-ref backfill.
        public_id: { type: string, nullable: true }
        # Original generic Recording number. It remains available only for
        # resolving historical links and comment anchors.
        legacy_id: { type: string, nullable: true, pattern: '^\d+$' }
        title: { type: string }
        content_html: { type: string, nullable: true }
        content_json: { type: object, nullable: true }
        content_markdown:
          type: string
          nullable: true
          description: >-
            Canonical body (GFM). content_html is rendered server-side from
            it; null on rows not yet migrated from Tiptap JSON.
        content_version:
          $ref: "#/components/schemas/ContentVersion"
        preview_text:
          type: string
          description: >-
            Lists only. The body as plain text (HTML tags removed, entities
            left encoded, e.g. "&amp;"), cut to 80 UTF-16 units; "" without a
            body.
        excerpt:
          type: string
          nullable: true
          description: >-
            Lists only. Up to 100 characters of the body's text, images and
            videos shown as [图片] / [视频]; null without a body.
        preview_html:
          type: string
          description: >-
            Document lists only. The first 3000 characters of content_html
            (may end inside a tag), for thumbnails.
        comments_count: { type: integer }
        type: { type: string }
        status: { type: string, enum: [draft, active, archived, trashed] }
        recordable_type: { type: string }
        app_url: { type: string, nullable: true }
        account_id: { type: string, format: uuid }
        account_public_id: { $ref: "#/components/schemas/PublicId" }
        project_id: { type: string, format: uuid }
        project_public_id: { $ref: "#/components/schemas/PublicId" }
        creator_id: { type: string, format: uuid }
        # Drive folder holding this recording (documents only so far); null
        # at the project root and for non-drive types.
        parent_id: { type: string, format: uuid, nullable: true }
        # Single-document responses only (GET documents/:id, GET
        # recordings/:ref for a D… ref): the folder's ref and the folder
        # chain from the project root down to it; null / [] at the root.
        parent_public_id: { type: string, nullable: true }
        ancestors:
          type: array
          items: { $ref: "#/components/schemas/FolderCrumb" }
        created_at: { type: string, format: date-time }
        updated_at:
          type: string
          format: date-time
          description: Last change of the recording or of its body, whichever is later.
        reactions:
          type: array
          items:
            $ref: "#/components/schemas/Reaction"
        # Comments and discussions (Comment, Message) only.
        edited_by:
          description: >-
            Comments and discussions only: the last member other than the
            author who edited the title or body (the author's own later edits
            keep it); null when only the author edited it.
          $ref: "#/components/schemas/UserRef"
        trashed_by:
          description: >-
            Comments and discussions only: the member other than the author
            who trashed it; null otherwise (cleared by the author's own trash
            and on restore). A comment with status "trashed" and a trashed_by
            is a placeholder in GET .../recordings/:id/comments — its title
            and content are empty.
          $ref: "#/components/schemas/UserRef"

    # A person referenced from content or another person: edited_by,
    # trashed_by, a member's managed_by, schedule participants, publication
    # recipients, revision authors. id is the user UUID; public_id is their
    # member ref in the payload's account (null for a user who never belonged
    # to it). Nullable: null when nobody is referenced.
    # 回收站 entry (GET /projects/{project_id}/trashed_recordings).
    TrashedRecording:
      type: object
      x-projection: true
      required: [id, public_id, title, recordable_type, account_id, project_id, parent_id, app_url, creator, deleted_by,
                 trashed_at, purge_at, restores_to, items_count, byte_size, content_type, can_restore, can_purge]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        public_id: { type: string, nullable: true, description: The ref it keeps when restored. }
        title: { type: string, nullable: true }
        recordable_type: { type: string, enum: [Task, Message, Document, Upload, Folder, Schedule, Goal, ForwardedEmail] }
        account_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        parent_id: { type: string, format: uuid, nullable: true, description: The folder it was in (restored to the drive root when that folder is gone). }
        app_url: { type: string, nullable: true }
        creator: { $ref: "#/components/schemas/UserRef" }
        deleted_by: { $ref: "#/components/schemas/UserRef" }
        trashed_at: { type: string, format: date-time, nullable: true }
        purge_at: { type: string, format: date-time, nullable: true, description: When it is purged for good (trashed_at + 30 days). }
        restores_to: { type: string, enum: [active, archived, draft] }
        items_count: { type: integer, minimum: 0, description: Rows that went in with it and come back or go with it (a folder's contents, later cancelled meetings). }
        byte_size: { type: integer, nullable: true, description: Uploads only. }
        content_type: { type: string, nullable: true, description: Uploads only. }
        can_restore: { type: boolean }
        can_purge: { type: boolean }

    # 已删除的项目 row (GET /trashed_projects, DELETE /projects/{id}).
    TrashedProject:
      type: object
      x-projection: true
      required: [id, public_id, account_id, name, icon, color, visibility, project_type, deleted_by, trashed_at, purge_at,
                 restores_to, can_restore, can_purge]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        public_id: { $ref: "#/components/schemas/PublicId" }
        account_id: { type: string, format: uuid }
        name: { type: string }
        icon: { type: string, nullable: true }
        color: { type: string, nullable: true }
        visibility: { type: string, enum: [invite_only, team] }
        project_type: { type: string }
        deleted_by: { $ref: "#/components/schemas/UserRef" }
        trashed_at: { type: string, format: date-time, nullable: true }
        purge_at: { type: string, format: date-time, nullable: true }
        restores_to: { type: string, enum: [active, archived] }
        can_restore: { type: boolean }
        can_purge: { type: boolean }

    UserRef:
      type: object
      x-projection: true
      nullable: true
      required: [id, public_id, name, avatar_url, kind]
      properties:
        id: { type: string, format: uuid, description: User UUID. }
        public_id: { type: string, nullable: true, pattern: '^U\d+$', description: 'Member ref ("U12") in this account.' }
        name: { type: string, nullable: true }
        avatar_url: { type: string, nullable: true }
        kind: { type: string, nullable: true, enum: [human, agent, null], description: null only for an unknown (deleted) user. }

    # Read-only snapshots shared across payloads (x-projection): clients show
    # them in place and never store them as rows.
    ProjectSummary:
      type: object
      x-projection: true
      required: [id, public_id, name, icon]
      properties:
        id: { type: string, format: uuid }
        public_id: { $ref: "#/components/schemas/PublicId" }
        name: { type: string }
        icon: { type: string, nullable: true }

    RecordingSummary:
      type: object
      x-projection: true
      required: [id, public_id, title, recordable_type, project_id, account_id, app_url]
      properties:
        id: { type: string, format: uuid }
        public_id: { type: string, nullable: true }
        title: { type: string }
        recordable_type: { type: string }
        project_id: { type: string, format: uuid }
        account_id: { type: string, format: uuid }
        app_url: { type: string, nullable: true }
        project_public_id: { $ref: "#/components/schemas/PublicId" }
        account_public_id: { $ref: "#/components/schemas/PublicId" }

    AccountSummary:
      type: object
      x-projection: true
      required: [id, public_id, name]
      properties:
        id: { type: string, format: uuid }
        public_id: { $ref: "#/components/schemas/PublicId" }
        name: { type: string, nullable: true }

    # A team scheduled for deletion (DELETE /accounts/{id}, GET
    # /accounts/deleted): restorable until purge_at, then purged for good.
    DeletedAccount:
      type: object
      x-projection: true
      required: [id, public_id, name, deleted_at, purge_at, deleted_by]
      properties:
        id: { type: string, format: uuid }
        public_id: { $ref: "#/components/schemas/PublicId" }
        name: { type: string, nullable: true }
        deleted_at: { type: string, format: date-time }
        purge_at: { type: string, format: date-time }
        deleted_by: { $ref: "#/components/schemas/UserRef" }

    # One folder of a drive path (ancestors), project root first.
    FolderCrumb:
      type: object
      x-projection: true
      required: [id, public_id, title]
      properties:
        id: { type: string, format: uuid }
        public_id: { type: string, nullable: true }
        title: { type: string }

    # POST .../attachments: an editor attachment's access URLs. Not stored
    # client-side; the token is pasted into a body as embed_html.
    Attachment:
      type: object
      x-projection: true
      required: [id, url, preview_url, raw_url, embed_html]
      properties:
        id: { type: string, description: Attachment token; also the :id of the GET endpoints }
        url: { type: string }
        preview_url: { type: string }
        raw_url: { type: string }
        embed_html:
          type: string
          description: >-
            Paste verbatim into content_markdown (as its own paragraph) to
            render the app's attachment card in a document. Writing plain
            links to the attachment URLs renders as links, not the card.

    # One full-text search hit: the indexed recording attributes as
    # Meilisearch formats them — title and content_html carry <em> highlight
    # markup and content_html is cropped around the match, so a hit is a
    # display snapshot, never a recording row.
    SearchHit:
      type: object
      x-projection: true
      required: [id]
      properties:
        id: { type: string, format: uuid }
        public_id: { type: string, nullable: true }
        recordable_type: { type: string }
        title: { type: string, nullable: true }
        content_html: { type: string, nullable: true }
        app_url: { type: string, nullable: true }
        account_id: { type: string }
        project_id: { type: string }
        creator_id: { type: string, nullable: true }
        status: { type: string }
        created_at: { type: string, nullable: true }
        updated_at: { type: string, nullable: true }

    # A mention / pick-list candidate (GET .../search/suggestion).
    MemberSuggestion:
      x-projection: true
      allOf:
        - $ref: "#/components/schemas/UserRef"
        - type: object
          properties:
            email: { type: string, nullable: true }
            status: { type: string, nullable: true }
            tagline: { type: string, nullable: true }
            membership_status: { type: string, enum: [active, paused], nullable: true }

    # A drive entry: Document (D…), Upload (F…) or Folder (B…) recording.
    # Shared fields are always present; type-specific ones appear only on
    # their type (documents: body; uploads: file fields + URLs; folders:
    # children_count). Uploads and folders carry no rich-text fields.
    DriveItem:
      type: object
      x-entity: *recordable
      required:
        [id, public_id, legacy_id, title, type, recordable_type, recordable_id, status, app_url, account_id,
         account_public_id, project_id, project_public_id, creator_id, parent_id, parent_public_id, ancestors,
         comments_count, created_at, updated_at, reactions]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        public_id: { type: string, nullable: true }
        legacy_id: { type: string, nullable: true, pattern: '^\d+$' }
        title: { type: string }
        type: { type: string, enum: [Document, Upload, Folder] }
        recordable_type: { type: string, enum: [Document, Upload, Folder] }
        recordable_id: { type: string, format: uuid }
        status: { type: string, enum: [draft, active, archived, trashed] }
        app_url: { type: string, nullable: true }
        account_id: { type: string, format: uuid }
        account_public_id: { $ref: "#/components/schemas/PublicId" }
        project_id: { type: string, format: uuid }
        project_public_id: { $ref: "#/components/schemas/PublicId" }
        creator_id: { type: string, format: uuid }
        parent_id: { type: string, format: uuid, nullable: true, description: Recording id of the containing folder; null at the project root. }
        parent_public_id: { type: string, nullable: true }
        ancestors:
          type: array
          description: Folder chain from the project root down to the parent.
          items: { $ref: "#/components/schemas/FolderCrumb" }
        comments_count: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        reactions: { type: array, items: { $ref: "#/components/schemas/Reaction" } }
        # Document (body keys absent from the list with content=summary)
        content_html: { type: string, nullable: true }
        content_json: { type: object, nullable: true }
        content_markdown: { type: string, nullable: true }
        content_version: { $ref: "#/components/schemas/ContentVersion" }
        preview_text: { type: string, description: "Plain-text start of the body (80 UTF-16 units, entities encoded)." }
        excerpt: { type: string, nullable: true, description: "Up to 100 characters of the body's text." }
        preview_html: { type: string, description: "First 3000 characters of content_html (thumbnails)." }
        # Folder
        children_count: { type: integer, description: Active items directly inside the folder. }
        # Upload
        filename: { type: string, description: Original filename at upload time. }
        content_type: { type: string, nullable: true }
        byte_size: { type: integer }
        kind:
          type: string
          enum: [image, video, audio, pdf, text, spreadsheet, word, presentation, archive, other]
        extension: { type: string, nullable: true }
        previewable: { type: boolean }
        width: { type: integer, nullable: true }
        height: { type: integer, nullable: true }
        download_url: { type: string }
        raw_url: { type: string }
        content_url: { type: string }
        thumbnail_url: { type: string, nullable: true }
        preview_url: { type: string, nullable: true }

    DriveItemInput:
      type: object
      properties:
        recording:
          type: object
          properties:
            id: { type: string, format: uuid }
            title: { type: string, description: "1–255 characters after trimming (422 otherwise)." }
            parent_id: { type: string, nullable: true, description: Folder ref ("B12") or uuid. }

    ContentVersion:
      type: integer
      minimum: 0
      description: >-
        Body version for compare-and-set saves. Starts at 0 and increases by
        one each time content_markdown / content_html / content_json actually
        changes (any write path). Send it back as recording.content_version
        when saving a body edit.

    RecordingRevision:
      type: object
      description: >-
        One snapshot of a recording's title and whole body. Events: baseline
        (the body a recording had before its history began, by its creator),
        create (created published), edit (saves; one author's saves within
        10 minutes update the same edit in place, so updated_at moves),
        publish (draft published), restore (a revision brought back;
        restored_from_number names it).
      required: [number, event, title, author_ids, authors, content_version, restored_from_number, created_at, updated_at]
      properties:
        number: { type: integer, minimum: 1, description: Per-recording sequence. }
        event: { type: string, enum: [baseline, create, edit, publish, restore] }
        title: { type: string, description: The recording title at the time (a task's own title for its description history). }
        author_ids: { type: array, items: { type: string, format: uuid } }
        authors:
          type: array
          items: { $ref: "#/components/schemas/UserRef" }
        content_version: { $ref: "#/components/schemas/ContentVersion" }
        restored_from_number: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    RecordingRevisionDetail:
      allOf:
        - $ref: "#/components/schemas/RecordingRevision"
        - type: object
          required: [content_markdown, content_html]
          properties:
            content_markdown: { type: string, nullable: true, description: null for legacy html-only bodies. }
            content_html: { type: string, nullable: true }

    ContentVersionInput:
      type: integer
      minimum: 0
      nullable: true
      description: >-
        Update only. The content_version the edit started from (recommended
        for every body edit). When present and the request changes the body,
        a stale value is refused with 409 content_conflict and nothing is
        written; the problem body carries the current content_version. Omit
        it (or send null) for last-write-wins. Title- or metadata-only
        updates never conflict.

    # Create/update bodies of the recording types, one per type: each
    # controller permits `recording` (RecordablesController#recording_params)
    # plus its own recordable key (#recordable_params).
    RecordingFields:
      description: |
        The fields every recording type shares, sent under `recording`. `id`
        is create-only (a client-generated UUID).
      type: object
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        status: { type: string }
        content_html: { type: string, nullable: true, deprecated: true }
        content_json: { deprecated: true }
        content_markdown:
          type: string
          nullable: true
          description: >-
            Canonical body (GFM). When present the server renders and
            sanitizes content_html from it, ignoring any client-sent
            content_html/content_json. Preferred over content_html and
            content_json for new integrations. Hard cap 131072 bytes
            (128KB, UTF-8 bytes — not characters); a larger body is
            rejected with 422 validation_error ("正文超出 128KB 上限"):
            upload long content as an attachment or split it into
            multiple documents. Pre-existing over-limit rows stay
            readable and editable as long as the body is untouched.
        content_version:
          $ref: "#/components/schemas/ContentVersionInput"

    TaskInput:
      type: object
      description: Create/update body of a task (tasks_controller).
      properties:
        project_id:
          type: string
          format: uuid
          description: createAccountTask only — the project to create the task in.
        recording: { $ref: "#/components/schemas/RecordingFields" }
        task: { $ref: "#/components/schemas/TaskFields" }

    TaskFields:
      type: object
      description: |
        Task fields (tasks_controller#recordable_params). A
        task_status_id must be a status of the task's type — on update,
        of the requested task_type_id, else the current type — or the
        request fails with 422 validation_error (field task_status_id).
        On create it defaults to the type's first status; priority_id
        defaults to the team's first priority. An update that changes
        task_type_id without a task_status_id keeps the current status
        when the new type has it, otherwise moves the task to the new
        type's first status in the same stage, otherwise to its first
        status (422 when the new type has no statuses); the response
        carries the resulting task_status_id. A task_type_id of another
        team is 404. Assigning on create notifies the assignee
        (assigned/assigned), as assigning later does.
      properties:
        id: { type: string, format: uuid }
        task_type_id: { type: string, format: uuid }
        task_status_id: { type: string, format: uuid }
        assignee_id: { type: string, format: uuid, nullable: true }
        priority_id: { type: string, format: uuid }
        due_date: { type: string, format: date, nullable: true }
        story_points: { type: number, nullable: true }
        version_id: { type: string, format: uuid, nullable: true }

    DocumentInput:
      type: object
      description: Create/update body of a document (documents_controller). A new document is a draft unless `recording.status` says otherwise.
      properties:
        project_id:
          type: string
          format: uuid
          description: createAccountDocument only — the project to create the document in.
        recording:
          allOf:
            - $ref: "#/components/schemas/RecordingFields"
            - type: object
              properties:
                parent_id:
                  type: string
                  nullable: true
                  description: Drive folder ref ("B12") or uuid to create/keep the document in.
        document:
          type: object
          properties:
            id: { type: string, format: uuid }

    MessageInput:
      type: object
      description: Create/update body of a discussion (messages_controller).
      properties:
        recording: { $ref: "#/components/schemas/RecordingFields" }
        message:
          type: object
          properties:
            id: { type: string, format: uuid }

    GoalInput:
      type: object
      description: Create/update body of a goal (goals_controller).
      properties:
        recording: { $ref: "#/components/schemas/RecordingFields" }
        goal: { $ref: "#/components/schemas/GoalFields" }

    GoalFields:
      type: object
      description: Goal fields (goals_controller#recordable_params). Whole numbers; target must be greater than starting.
      properties:
        id: { type: string, format: uuid }
        starting: { type: integer }
        target: { type: integer }
        current: { type: integer }

    CommentInput:
      type: object
      description: Create/update body of a comment (recordings/comments_controller).
      properties:
        recording: { $ref: "#/components/schemas/RecordingFields" }
        comment: { $ref: "#/components/schemas/CommentFields" }

    CommentFields:
      type: object
      properties:
        id: { type: string, format: uuid }
        comment_type: { type: string, enum: [general, inline] }
        parent_id:
          type: string
          format: uuid
          nullable: true
          description: >-
            Reply to a top-level comment (its recording `id`) on the same
            recording. Threads are two levels: a reply's parent_id must be
            a top-level comment, else 422; to answer a reply, use its
            parent_id.
        quote_text: { type: string, nullable: true }
        inline_position:
          { type: integer, nullable: true, deprecated: true }
        anchor_exact: { type: string, nullable: true }
        anchor_prefix: { type: string, nullable: true }
        anchor_suffix: { type: string, nullable: true }

    # A task recording = Recording base + the Task recordable fields,
    # flattened at the top level (TaskRecordingSerializer; tasks#show renders
    # app/views/api/v1/tasks/_task.json.jbuilder, pinned to the same shape).
    Task:
      x-entity: *recordable
      allOf:
        - $ref: "#/components/schemas/Recording"
        - type: object
          required:
            - due_date
            - start_date
            - rank
            - story_points
            - priority_id
            - task_type_id
            - task_status_id
            - version_id
            - assignee_id
            - backlog_at
            - todo_at
            - in_progress_at
            - completed_at
            - cancelled_at
          properties:
            recordable_type: { type: string, enum: [Task] }
            due_date: { type: string, format: date, nullable: true }
            start_date: { type: string, format: date, nullable: true }
            rank: { type: string }
            story_points: { type: integer, nullable: true }
            priority_id: { type: string, format: uuid }
            task_type_id: { type: string, format: uuid }
            task_status_id: { type: string, format: uuid }
            version_id: { type: string, format: uuid, nullable: true }
            assignee_id: { type: string, format: uuid, nullable: true }
            backlog_at: { type: string, format: date-time, nullable: true }
            todo_at: { type: string, format: date-time, nullable: true }
            in_progress_at: { type: string, format: date-time, nullable: true }
            completed_at: { type: string, format: date-time, nullable: true }
            cancelled_at: { type: string, format: date-time, nullable: true }
            project:
              description: >-
                Cross-project lists only (GET .../my/tasks, GET
                .../users/{user_id}/tasks): the task's project, so a row can
                show where it lives without loading the project list.
              allOf:
                - $ref: "#/components/schemas/ProjectSummary"

    # A schedule recording = Recording base + the Schedule fields, flattened
    # (ScheduleRecordingSerializer). Lists, single responses and realtime
    # payloads share this shape.
    Schedule:
      x-entity: *recordable
      allOf:
        - $ref: "#/components/schemas/Recording"
        - type: object
          required:
            - recordable_id
            - starts_at
            - ends_at
            - all_day
            - time_zone
            - starts_on
            - ends_on
            - location
            - link
            - participants
            - participant_ids
            - participants_version
            - project
          properties:
            type: { type: string, enum: [Schedule] }
            recordable_type: { type: string, enum: [Schedule] }
            recordable_id: { type: string, format: uuid, description: The schedules row id. }
            starts_at: { type: string, format: date-time, description: Start instant (UTC). All-day events start at 00:00 in time_zone. }
            ends_at: { type: string, format: date-time, description: "End instant (UTC), EXCLUSIVE." }
            all_day: { type: boolean }
            time_zone: { type: string, description: IANA time zone the event is defined in., example: Asia/Shanghai }
            starts_on: { type: string, format: date, nullable: true, description: All-day only — first day (local); null for timed events. }
            ends_on: { type: string, format: date, nullable: true, description: "All-day only — last day (local), INCLUSIVE; null for timed events." }
            location: { type: string, nullable: true }
            link: { type: string, nullable: true, description: Meeting / live link (https). }
            participants:
              type: array
              items: { $ref: "#/components/schemas/UserRef" }
            participant_ids:
              type: array
              description: User UUIDs of participants, same order as participants.
              items: { type: string, format: uuid }
            participants_version: { type: string, description: Opaque participant snapshot token for replacement writes. }
            project: { $ref: "#/components/schemas/ProjectSummary" }
            series_id: { type: string, format: uuid, nullable: true }
            recurrence: { $ref: "#/components/schemas/ScheduleRecurrence" }
            detached: { type: boolean, description: This occurrence was individually changed or cancelled. }
            occurrence_number: { type: integer, nullable: true, minimum: 0 }

    ScheduleRecurrence:
      type: object
      nullable: true
      description: "Calendar-date rule in the event timezone. Daily/weekly/monthly/yearly; invalid month-end or leap dates are skipped. until is inclusive, count includes the first occurrence. Specify at most one of until/count. Open-ended series materialize one year ahead and renew daily. On a schedule it is the rule the series still follows: once the series is stopped (PATCH edit_scope=following with recurrence null, or DELETE edit_scope=following), the meetings it kept report the shortened rule — count becomes the number of kept meetings (count and open-ended rules), until becomes the last kept meeting's date (until rules) — and null when no meeting of the series was kept."
      properties:
        freq: { type: string }
        interval: { type: integer, description: "1–365; defaults to 1." }
        by_weekday: { type: array, items: { type: integer }, description: "Weekly only. 0=Sunday to 6=Saturday; must include the initial weekday." }
        until: { type: string, nullable: true, description: "Inclusive YYYY-MM-DD; not before the first occurrence." }
        count: { type: integer, nullable: true, description: "1–1000 occurrences." }

    CalendarFeed:
      x-entity: calendarFeed
      type: object
      required: [id, name, scope, project_ids, member_ids, include_tasks, created_at, subscription_path]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        scope: { type: string, enum: [mine, all, members] }
        project_ids: { type: array, items: { type: string, format: uuid } }
        member_ids: { type: array, items: { type: string, format: uuid } }
        include_tasks: { type: boolean }
        created_at: { type: string, format: date-time }
        subscription_path: { type: string, nullable: true, description: "Token-bearing relative ICS URL; returned only on creation/rotation. Store privately; listing returns null." }

    CalendarFeedInput:
      type: object
      properties:
        calendar_feed:
          type: object
          properties:
            name: { type: string, maxLength: 100 }
            scope: { type: string }
            project_ids: { type: array, items: { type: string } }
            member_ids: { type: array, items: { type: string } }
            include_tasks: { type: boolean }

    ScheduleInput:
      type: object
      description: |
        Create/update body. `recording` carries the shared fields, `schedule`
        the time/place fields. Timed: starts_at (+ ends_at). All-day:
        all_day: true + starts_on (+ ends_on, inclusive). Fields of the other
        mode are ignored.
      properties:
        edit_scope: { type: string, description: "Update only. this (default) creates an exception; following edits the selected future instance and later instances, keeping earlier meetings and exceptions. Recurrence changes require following; recurrence null stops repeating after this instance." }
        recording:
          type: object
          properties:
            id: { type: string, format: uuid }
            title: { type: string, description: "1–255 characters after trimming; required on create." }
            content_markdown: { type: string, nullable: true, description: "Agenda / minutes (GFM, ≤ 128KB)." }
            content_html: { type: string, nullable: true, deprecated: true }
            content_json: { deprecated: true }
            content_version:
              $ref: "#/components/schemas/ContentVersionInput"
        schedule:
          type: object
          properties:
            id: { type: string, format: uuid }
            all_day: { type: boolean, description: "Explicit null or invalid values return 422 validation_error (all_day_invalid)." }
            starts_at: { type: string, description: "ISO 8601 date-time with offset, e.g. 2026-10-20T14:00:00+08:00." }
            ends_at: { type: string, description: Exclusive end; default starts_at + 1 hour on create. }
            starts_on: { type: string, description: "YYYY-MM-DD (all-day)." }
            ends_on: { type: string, description: "YYYY-MM-DD, inclusive (all-day); default starts_on." }
            time_zone: { type: string, description: "IANA name; default Asia/Shanghai." }
            location: { type: string, nullable: true }
            link: { type: string, nullable: true, description: "https:// URL, ≤ 2048 characters." }
            recurrence: { $ref: "#/components/schemas/ScheduleRecurrence" }
            participants_version: { type: string, description: Required on update when participants is present; use the token from the schedule snapshot. }
            participants:
              type: array
              description: Member refs ("U12") or user UUIDs; replaces the list when present. Only [] clears it; null is invalid.
              items: { type: string }

    # No `required:` arrays on purpose: the controller answers a missing key
    # with RFC 9457 400 param_missing (committee would pre-empt it).
    ScheduleParticipantsInput:
      type: object
      description: >-
        participants.members is required (may be an empty array); a body
        without it or participants.version is answered with 400 param_missing.
        Use the snapshot participants_version as participants.version. A stale
        token returns 409 participants_conflict without changing any fields.
        Concurrent unique constraint violations return 409 conflict.
      properties:
        participants:
          type: object
          description: Required.
          properties:
            version: { type: string, description: Required. The participants_version from the opened schedule snapshot. }
            members:
              type: array
              description: Required. Member refs ("U12") or user UUIDs; [] clears the list.
              items: { type: string }

    # A goal recording = Recording base + the Goal recordable fields.
    Goal:
      x-entity: *recordable
      allOf:
        - $ref: "#/components/schemas/Recording"
        - type: object
          required: [starting, target, current]
          properties:
            recordable_type: { type: string, enum: [Goal] }
            starting: { type: integer }
            target: { type: integer }
            current: { type: integer }

    # A comment recording = Recording base + the Comment recordable fields.
    Comment:
      x-entity: *recordable
      allOf:
        - $ref: "#/components/schemas/Recording"
        - type: object
          required:
            [
              comment_type,
              parent_id,
              quote_text,
              inline_position,
              commentable_type,
              commentable_id,
              anchor_exact,
              anchor_prefix,
              anchor_suffix,
            ]
          properties:
            recordable_type: { type: string, enum: [Comment] }
            comment_type: { type: string }
            parent_id: { type: string, format: uuid, nullable: true }
            quote_text: { type: string, nullable: true }
            inline_position: { type: integer, nullable: true, deprecated: true }
            commentable_type: { type: string }
            commentable_id: { type: string, format: uuid }
            # Inline-comment anchor (W3C TextQuoteSelector over the document's
            # plain text): exact = the full selected text, prefix/suffix = the
            # surrounding context used to disambiguate repeated matches. The
            # body itself carries no anchor markup; clients re-locate the quote
            # at render time and treat a miss as an orphaned comment.
            anchor_exact: { type: string, nullable: true }
            anchor_prefix: { type: string, nullable: true }
            anchor_suffix: { type: string, nullable: true }

    Following:
      x-entity: following
      type: object
      required:
        [id, topic_id, topic_type, user_id, status, created_at, updated_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        topic_id: { type: string, format: uuid }
        topic_type: { type: string }
        user_id: { type: string, format: uuid }
        status: { type: string, enum: [active, trashed] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Bookmark:
      x-entity: bookmark
      type: object
      required:
        [
          id,
          user_id,
          account_id,
          bookmarkable_type,
          bookmarkable_id,
          created_at,
          updated_at,
          bookmarkable,
          parent,
          excerpt,
        ]
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        account_id: { type: string, format: uuid }
        bookmarkable_type: { type: string }
        bookmarkable_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        bookmarkable:
          description: >-
            The bookmarked recording itself (id == bookmarkable_id); a
            bookmarked comment is a Comment summary whose title is a
            placeholder — label it with parent.title. Null when the
            bookmarkable is not a recording.
          nullable: true
          allOf: [{ $ref: "#/components/schemas/RecordingSummary" }]
        parent:
          description: The bookmarked comment's top-level recording; null unless bookmarkable is a Comment.
          nullable: true
          allOf: [{ $ref: "#/components/schemas/RecordingSummary" }]
        excerpt: { type: string, nullable: true, description: Up to 100 characters of the bookmarked recording's text (the comment's own text for a comment). }

    TaskTypeStatus:
      x-entity: taskTypeStatus
      type: object
      required: [id, task_type_id, task_status_id, created_at, updated_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        task_type_id: { type: string, format: uuid }
        task_status_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Notification:
      x-entity: notification
      type: object
      required:
        - id
        - account_id
        - account_public_id
        - notifiable_type
        - notifiable_id
        - action
        - details
        - actor_id
        - reason
        - read_at
        - created_at
        - updated_at
        - notifiable
      properties:
        id: { type: string, format: uuid }
        account_id: { type: string, format: uuid }
        account_public_id: { $ref: "#/components/schemas/PublicId" }
        notifiable_type: { type: string }
        notifiable_id: { type: string, format: uuid }
        action: { type: string }
        details: { type: object, nullable: true }
        actor_id: { type: string, format: uuid }
        reason: { type: string, nullable: true }
        read_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        notifiable: { $ref: "#/components/schemas/NotificationTarget" }

    NotificationTarget:
      x-projection: true
      allOf:
        - $ref: "#/components/schemas/RecordingSummary"
        - type: object
          required: [project_public_id, account_public_id, project]
          properties:
            legacy_id: { type: string, nullable: true }
            project: { $ref: "#/components/schemas/ProjectSummary" }

    Activity:
      x-entity: activity
      type: object
      required:
        [
          id,
          trackable_type,
          trackable_id,
          action,
          details,
          account_id,
          account_public_id,
          project_id,
          project_public_id,
          actor_id,
          created_at,
          updated_at,
          project,
        ]
      properties:
        id: { type: string, format: uuid }
        trackable_type: { type: string }
        trackable_id: { type: string, format: uuid }
        action: { type: string }
        details: { type: object, nullable: true }
        account_id: { type: string, format: uuid }
        account_public_id: { $ref: "#/components/schemas/PublicId" }
        project_id: { type: string, format: uuid, nullable: true }
        project_public_id: { type: string, nullable: true, pattern: '^\d+$' }
        actor_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        project:
          description: The activity's project, or null when it has none.
          nullable: true
          allOf: [{ $ref: "#/components/schemas/ProjectSummary" }]

    ProjectInput:
      type: object
      description: Writable attributes for create/update (wrapped under `project`).
      properties:
        name: { type: string }
        icon: { type: string, nullable: true }
        color: { type: string, nullable: true }
        stage: { type: string }
        status: { type: string }
        description: { type: string, nullable: true }
        start_date: { type: string, format: date, nullable: true }
        target_date: { type: string, format: date, nullable: true }
        visibility: { type: string }
        project_type: { type: string }
        task_type_ids:
          type: array
          items: { type: string, format: uuid }
        cover_url:
          type: string
          nullable: true
          description: >-
            https URL of a gallery cover image. Setting it removes an uploaded
            cover; send it and cover_image as null to remove the cover.
        cover_image:
          type: string
          nullable: true
          description: >-
            Active Storage direct-upload signed_id of an uploaded cover image
            (JPG, PNG, WebP, GIF or AVIF, at most 20MB). Clears cover_url.
        cover_position:
          type: integer
          minimum: 0
          maximum: 100
          description: Vertical focal point of the cover, 0 (top) to 100 (bottom).

    Account:
      x-entity: account
      type: object
      required:
        [
          id,
          public_id,
          name,
          created_at,
          updated_at,
          task_statuses,
          task_type_statuses,
          task_types,
          reaction_types,
          priorities,
        ]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        public_id: { $ref: "#/components/schemas/PublicId" }
        name: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        task_statuses:
          { type: array, items: { $ref: "#/components/schemas/TaskStatus" } }
        task_type_statuses:
          {
            type: array,
            items: { $ref: "#/components/schemas/TaskTypeStatus" },
          }
        task_types:
          { type: array, items: { $ref: "#/components/schemas/TaskType" } }
        reaction_types:
          { type: array, items: { $ref: "#/components/schemas/ReactionType" } }
        priorities:
          { type: array, items: { $ref: "#/components/schemas/Priority" } }

    # No additionalProperties:false — account_users flattens role/timestamps
    # onto this shape via allOf (see AccountUser).
    # No public_id: the user's global numeric id left the wire in v1.6 —
    # cross-account identity is the UUID; public_id everywhere else is a
    # typed per-account ref (api-conventions §2).
    User:
      x-entity: user
      type: object
      required: [id, name, email, tagline, kind, status, push_enabled, can_create_team]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        email: { type: string, nullable: true }
        tagline: { type: string, nullable: true }
        kind: { type: string, enum: [human, agent] }
        status: { type: string }
        push_enabled: { type: boolean }
        can_create_team:
          type: boolean
          description: Whether this user may create a new team from waitlist approval.
        avatar_url: { type: string, nullable: true }

    UserAccount:
      x-entity: userAccount
      type: object
      required: [id, user_id, account_id, role, created_at, updated_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        account_id: { type: string, format: uuid }
        role: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AccountUser:
      x-entity: accountUser
      allOf:
        - $ref: "#/components/schemas/User"
        - type: object
          required: [public_id, role, created_at, updated_at]
          properties:
            public_id:
              type: string
              pattern: '^U\d+$'
              nullable: true
              description: >-
                Member ref ("U12") — the membership's per-account typed ref
                (the payload is keyed by user: id is the user UUID). Also a
                valid {id} for the account users routes.
            role: { type: string, nullable: true }
            membership_status: { type: string, enum: [active, paused, left] }
            managed_by:
              description: Agents only — the human member responsible for the agent; null for humans.
              $ref: "#/components/schemas/UserRef"
            left_at: { type: string, format: date-time, nullable: true }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }
            last_active_at:
              type: string
              format: date-time
              nullable: true
              description: >-
                Member directory (GET .../users, GET .../users/{id}) and GET .../agents
                only. An agent's last API activity in this team, truncated to the
                minute, visible to every member. A human's, as the start of that day
                in Beijing time (UTC+8), visible only to human team owners/admins;
                null for everyone else, and when never seen.

    ManagedAgent:
      x-entity: managedAgent
      allOf:
        - $ref: "#/components/schemas/AccountUser"
        - type: object
          required: [last_active_at, open_task_count, unresponded_count]
          properties:
            open_task_count: { type: integer, minimum: 0 }
            unresponded_count: { type: integer, minimum: 0 }

    ReceivedNotification:
      x-entity: receivedNotification
      allOf:
        - $ref: "#/components/schemas/Notification"
        - type: object
          required: [responded, last_activity_at]
          properties:
            responded: { type: boolean }
            last_activity_at: { type: string, format: date-time, nullable: true }

    Member:
      x-entity: projectMember
      type: object
      required: [id, user_id, project_id, role]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        role: { type: string }

    Tool:
      x-entity: tool
      type: object
      required:
        [
          id,
          name,
          slug,
          description,
          status,
          position,
          project_id,
          project_public_id,
          created_at,
          updated_at,
        ]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        slug: { type: string, nullable: true }
        description: { type: string, nullable: true }
        status: { type: string, nullable: true }
        position: { type: integer }
        project_id: { type: string, format: uuid }
        project_public_id: { $ref: "#/components/schemas/PublicId" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    QuickAccess:
      type: object
      required:
        [project_id, project_public_id, name, icon, account_id, account_public_id, created_at, updated_at]
      additionalProperties: false
      properties:
        project_id: { type: string, format: uuid }
        project_public_id: { $ref: "#/components/schemas/PublicId" }
        name: { type: string }
        icon: { type: string, nullable: true }
        account_id: { type: string, format: uuid }
        account_public_id: { $ref: "#/components/schemas/PublicId" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ProjectTaskType:
      x-entity: projectTaskType
      type: object
      required: [id, project_id, task_type_id, created_at, updated_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        task_type_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    EmailForward:
      x-entity: emailForward
      type: object
      required: [id, project_id, email_address, status, created_at, updated_at]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        email_address: { type: string }
        status: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ProjectConfig:
      x-entity: projectConfig
      type: object
      required:
        [
          id,
          project_id,
          iteration_starts_on,
          iteration_length_in_weeks,
          iteration_auto_create_count,
          created_at,
          updated_at,
        ]
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        iteration_starts_on: { type: integer }
        iteration_length_in_weeks: { type: integer }
        iteration_auto_create_count: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Invitation:
      x-entity: invitation
      type: object
      required:
        [
          id,
          token,
          expires_at,
          account_id,
          project_id,
          creator_id,
          created_at,
          updated_at,
          account,
          project,
          creator,
          email,
          accepted_at,
          scope,
          delivery,
          expired,
        ]
      properties:
        id: { type: string, format: uuid }
        token:
          type: string
          nullable: true
          description: >-
            Bearer credential for /join. Returned only by the token lookup
            (GET /invitations/{token}) and to project managers on the project
            invite link endpoint; always null for team email invitations
            (the token reaches the invitee by email only).
        expires_at: { type: string, format: date-time, nullable: true }
        account_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid, nullable: true }
        creator_id: { type: string, format: uuid }
        email: { type: string, nullable: true }
        accepted_at: { type: string, format: date-time, nullable: true }
        scope: { type: string, enum: [project, team] }
        delivery: { type: string, enum: [link, email] }
        expired: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        account: { $ref: "#/components/schemas/AccountSummary" }
        project:
          description: The invited-to project; null for team invitations.
          x-projection: true
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ProjectSummary"
        creator: { $ref: "#/components/schemas/User" }

    # A forwarded-email recording = Recording base + the ForwardedEmail fields.
    ForwardedEmail:
      x-entity: *recordable
      allOf:
        - $ref: "#/components/schemas/Recording"
        - type: object
          required: [sender_email, sender_name, subject, received_at, files]
          properties:
            recordable_type: { type: string, enum: [ForwardedEmail] }
            sender_email: { type: string }
            sender_name: { type: string, nullable: true }
            subject: { type: string, nullable: true }
            received_at: { type: string, format: date-time }
            files:
              type: array
              items: { $ref: "#/components/schemas/AttachedFile" }

    # A file attached to a forwarded email (Active Storage attachment id).
    AttachedFile:
      type: object
      x-projection: true
      properties:
        id: { type: integer }
        filename: { type: string }
        content_type: { type: string, nullable: true }
        byte_size: { type: integer }
        url: { type: string }
        image: { type: boolean }

    Plan:
      type: object
      required: [code, name, per_seat, price_cents, currency, interval, storage_limit_bytes]
      properties:
        code: { type: string, example: pro_yearly }
        name: { type: string, example: 专业版（年付） }
        per_seat: { type: boolean }
        price_cents: { type: integer, description: 按席位套餐为单人单价；整包套餐为总价 }
        currency: { type: string, example: CNY }
        interval: { type: string, enum: [month, year] }
        storage_limit_bytes: { type: integer, format: int64 }

    Order:
      x-entity: order
      type: object
      required: [id, status, amount_cents, currency, period_start, period_end, payment_method, plan_code]
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [pending, paid, void] }
        amount_cents: { type: integer }
        currency: { type: string, example: CNY }
        seats: { type: integer, nullable: true }
        period_start: { type: string, format: date-time }
        period_end: { type: string, format: date-time }
        payment_method: { type: string, example: manual_transfer }
        plan_code: { type: string, example: pro_yearly }

    SubscriptionSnapshot:
      type: object
      required: [status, plan, member_count, storage_used_bytes, storage_limit_bytes]
      properties:
        status: { type: string, enum: [trialing, active, expired, canceled, suspended] }
        trial_ends_at: { type: string, format: date-time, nullable: true }
        current_period_end: { type: string, format: date-time, nullable: true }
        days_remaining: { type: integer, nullable: true }
        member_count: { type: integer }
        storage_used_bytes: { type: integer, format: int64 }
        storage_limit_bytes: { type: integer, format: int64 }
        plan: { $ref: "#/components/schemas/Plan" }

    # --- OAuth (MCP clients) ---------------------------------------------

    # RFC 6749 §5.2-shaped error of the /auth_api OAuth endpoints (not RFC 9457).
    OAuthError:
      type: object
      required: [error]
      properties:
        error: { $ref: "#/components/schemas/OAuthErrorCode" }
        error_description: { type: string }

    OAuthErrorCode:
      type: string
      description: |
        RFC 6749 / 7591 / 8707 codes, plus login_required (no browser session)
        and admin_confirmation_required (an admin-role agent was chosen
        without confirm_admin: true). See docs/api-conventions.md §12.
      enum:
        - invalid_request
        - invalid_client
        - invalid_grant
        - invalid_scope
        - invalid_target
        - unsupported_response_type
        - access_denied
        - login_required
        - admin_confirmation_required
        - invalid_redirect_uri
        - invalid_client_metadata
        - rate_limited

    OAuthProtectedResourceMetadata:
      type: object
      required: [resource, authorization_servers, scopes_supported, bearer_methods_supported]
      properties:
        resource: { type: string, example: "https://qunli.co/mcp" }
        authorization_servers: { type: array, items: { type: string, example: "https://qunli.co" } }
        scopes_supported: { type: array, items: { type: string, example: mcp } }
        bearer_methods_supported: { type: array, items: { type: string, enum: [header] } }
        resource_name: { type: string }
        resource_documentation: { type: string }

    OAuthAuthorizationServerMetadata:
      type: object
      required:
        - issuer
        - authorization_endpoint
        - token_endpoint
        - registration_endpoint
        - revocation_endpoint
        - scopes_supported
        - response_types_supported
        - grant_types_supported
        - token_endpoint_auth_methods_supported
        - code_challenge_methods_supported
      properties:
        issuer: { type: string, example: "https://qunli.co" }
        authorization_endpoint:
          type: string
          example: "https://qunli.co/auth/connect"
          description: The consent web page — open it in the browser with the authorization request in the query.
        token_endpoint: { type: string, example: "https://qunli.co/auth_api/token" }
        registration_endpoint: { type: string, example: "https://qunli.co/auth_api/register" }
        revocation_endpoint: { type: string, example: "https://qunli.co/auth_api/revoke" }
        scopes_supported: { type: array, items: { type: string, example: mcp } }
        response_types_supported: { type: array, items: { type: string, enum: [code] } }
        response_modes_supported: { type: array, items: { type: string, enum: [query] } }
        grant_types_supported:
          type: array
          items: { type: string, enum: [authorization_code, refresh_token] }
        token_endpoint_auth_methods_supported:
          type: array
          items: { type: string, enum: [none, client_secret_basic, client_secret_post] }
        code_challenge_methods_supported: { type: array, items: { type: string, enum: [S256] } }
        authorization_response_iss_parameter_supported:
          type: boolean
          description: The authorization response carries `iss` (RFC 9207).
        client_id_metadata_document_supported:
          type: boolean
          description: Present (true) only when the server accepts https URLs as client ids (CIMD); off in production.

    OAuthClientRegistrationRequest:
      type: object
      required: [client_name, redirect_uris]
      properties:
        client_name: { type: string, example: Claude Code, description: Shown on the consent page as self-reported by the client. }
        redirect_uris:
          type: array
          minItems: 1
          items: { type: string, example: "http://127.0.0.1:33418/callback" }
          description: https URLs, or http on 127.0.0.1 / [::1] / localhost (any port matches). No fragments.
        token_endpoint_auth_method:
          type: string
          enum: [none, client_secret_basic, client_secret_post]
          default: client_secret_basic
          description: "`none` for public clients (MCP clients); the others get a client_secret."
        grant_types:
          type: array
          items: { type: string, enum: [authorization_code, refresh_token] }
          default: [authorization_code]
        response_types:
          type: array
          items: { type: string, enum: [code] }
          default: [code]
        scope: { type: string, example: mcp, description: Only `mcp`; omitted means `mcp`. }
        client_uri: { type: string }
        logo_uri: { type: string }
        tos_uri: { type: string }
        policy_uri: { type: string }
        contacts: { type: array, items: { type: string } }
        software_id: { type: string }
        software_version: { type: string }

    OAuthClientRegistration:
      type: object
      required: [client_id, client_id_issued_at, client_name, redirect_uris, token_endpoint_auth_method, grant_types, response_types]
      description: The registered client metadata (RFC 7591 §3.2.1), plus the server-assigned fields.
      properties:
        client_id: { type: string }
        client_id_issued_at: { type: integer, description: Unix time. }
        client_secret:
          type: string
          description: Confidential clients only; returned once, keep it.
        client_secret_expires_at:
          type: integer
          description: 0 — the secret does not expire. Confidential clients only.
        client_name: { type: string }
        redirect_uris: { type: array, items: { type: string } }
        token_endpoint_auth_method: { type: string, enum: [none, client_secret_basic, client_secret_post] }
        grant_types: { type: array, items: { type: string, enum: [authorization_code, refresh_token] } }
        response_types: { type: array, items: { type: string, enum: [code] } }
        scopes: { type: string, example: mcp, description: "The registered scopes, space-separated." }
        client_uri: { type: string }
        logo_uri: { type: string }
        tos_uri: { type: string }
        policy_uri: { type: string }
        contacts: { type: array, items: { type: string } }
        software_id: { type: string }
        software_version: { type: string }

    OAuthTokenRequest:
      type: object
      required: [grant_type]
      properties:
        grant_type: { type: string, enum: [authorization_code, refresh_token] }
        client_id: { type: string, description: Public clients; confidential ones may use HTTP Basic instead. }
        client_secret: { type: string, description: Confidential clients using client_secret_post. }
        code: { type: string, description: "authorization_code: the code from the redirect." }
        redirect_uri: { type: string, description: "authorization_code: the redirect_uri of the authorization request." }
        code_verifier: { type: string, description: "authorization_code: the PKCE verifier (S256)." }
        refresh_token: { type: string, description: "refresh_token: the latest refresh token." }
        resource: { type: string, example: "https://qunli.co/mcp", description: "Optional; when sent, must be the MCP server (RFC 8707)." }

    OAuthTokenResponse:
      type: object
      required: [access_token, token_type, expires_in]
      properties:
        access_token:
          type: string
          description: "Send as `Authorization: Bearer <access_token>` to https://qunli.co/mcp or the REST API."
        token_type: { type: string, enum: [bearer] }
        expires_in: { type: integer, example: 3600, description: Seconds (one hour). }
        refresh_token:
          type: string
          description: A new one on every response; the previous refresh token no longer works. Valid 360 days after its last use.

    OAuthRevocationRequest:
      type: object
      required: [token]
      properties:
        token: { type: string, description: The access token or the refresh token. }
        token_type_hint: { type: string, enum: [access_token, refresh_token] }
        client_id: { type: string }
        client_secret: { type: string }

    OAuthRevocation:
      type: object
      properties:
        revoked_at: { type: string, format: date-time }
        token: { type: string, description: The token that was sent. }

    OAuthConsent:
      type: object
      required: [client, scope, resource, accounts]
      properties:
        client: { $ref: "#/components/schemas/OAuthConsentClient" }
        scope: { type: string, example: mcp }
        resource: { type: string, example: "https://qunli.co/mcp" }
        accounts:
          type: array
          description: |
            Every team the signed-in human is an active member of, with the
            agents they may authorize there (empty when none — offer to create
            one when can_create_agent).
          items: { $ref: "#/components/schemas/OAuthConsentAccount" }

    OAuthConsentClient:
      type: object
      required: [client_id, name, uri, logo_uri, metadata_document, redirect_uri, redirect_host, loopback_redirect]
      properties:
        client_id: { type: string }
        name:
          type: string
          description: Self-reported by the client (DCR request or metadata document) — label it as such.
        uri: { type: string, nullable: true }
        logo_uri: { type: string, nullable: true }
        metadata_document:
          type: boolean
          description: client_id is an https URL whose metadata document the server fetched (CIMD); otherwise registered via DCR.
        redirect_uri: { type: string }
        redirect_host:
          type: string
          description: Host the browser returns to — show it on the page.
        loopback_redirect:
          type: boolean
          description: The redirect goes to this computer (127.0.0.1 / [::1] / localhost) — show 本机应用.

    OAuthConsentAccount:
      x-projection: true
      type: object
      required: [id, public_id, name, role, can_create_agent, agents]
      properties:
        id: { type: string, format: uuid }
        public_id: { $ref: "#/components/schemas/PublicId" }
        name: { type: string, nullable: true }
        role:
          type: string
          enum: [owner, admin, member, external]
          description: The signed-in human's role in the team.
        can_create_agent: { type: boolean }
        agents:
          type: array
          items: { $ref: "#/components/schemas/OAuthConsentAgent" }

    OAuthConsentAgent:
      x-projection: true
      type: object
      required: [id, public_id, name, avatar_url, role, managed_by]
      properties:
        id: { type: string, format: uuid, description: Agent user UUID — send it back as agent_id. }
        public_id: { type: string, description: Member ref ("U12"). }
        name: { type: string, nullable: true }
        avatar_url: { type: string, nullable: true }
        role:
          type: string
          enum: [admin, member, external]
          description: admin agents need confirm_admin on approval.
        managed_by: { $ref: "#/components/schemas/UserRef" }

    OAuthAuthorizationDecision:
      type: object
      required: [client_id, response_type, code_challenge, code_challenge_method, resource, decision]
      additionalProperties: true
      properties:
        client_id: { type: string }
        redirect_uri: { type: string }
        response_type: { type: string, enum: [code] }
        state: { type: string }
        code_challenge: { type: string }
        code_challenge_method: { type: string, enum: [S256] }
        resource: { type: string }
        scope: { type: string }
        decision: { type: string, enum: [approve, deny] }
        account_id:
          type: string
          description: Team public_id or UUID. Required to approve.
        agent_id:
          type: string
          description: Agent user UUID or member ref. Required to approve; must be in account_id.
        confirm_admin:
          type: boolean
          nullable: true
          description: Must be true to approve an admin-role agent.

    OAuthRedirect:
      type: object
      required: [redirect_to]
      properties:
        redirect_to:
          type: string
          description: The client's redirect URI with code / error, state and iss in the query.

    # An OAuth authorization an agent holds (the row behind its access and
    # refresh token; tokens are never returned).
    AgentOauthGrant:
      x-entity: agentOauthGrant
      type: object
      required: [id, client, approved_by, scope, created_at, last_used_at, revoked_at]
      properties:
        id: { type: string, format: uuid }
        client: { $ref: "#/components/schemas/OAuthClientSummary" }
        approved_by:
          $ref: "#/components/schemas/UserRef"
          description: The human who approved it; null if they no longer exist.
        scope: { type: string, example: mcp }
        created_at: { type: string, format: date-time }
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: Approximate last successful authentication, updated at most once every five minutes.
        revoked_at: { type: string, format: date-time, nullable: true }

    OAuthClientSummary:
      type: object
      required: [client_id, name, uri, metadata_document]
      properties:
        client_id: { type: string }
        name: { type: string, description: Self-reported client name. }
        uri: { type: string, nullable: true }
        metadata_document: { type: boolean }

    ErrorCode:
      type: string
      description: |
        Short, stable identifier for client-side dispatch. Prefer this
        over parsing `type` URIs. New codes must be added here, to
        `AppController::ERROR_TITLES`, and to `docs/api-conventions.md` §5.
      enum:
        - validation_error
        - param_missing
        - pagination_error
        - not_found
        - unauthorized
        - forbidden
        - conflict
        - participants_conflict
        - content_conflict
        - cursor_regressed
        - rank_conflict
        - rate_limited
        - internal_error
        - account_read_only
        - payload_too_large
        - param_invalid

  # ===========================================================
  # Reusable error responses — endpoints reference these directly.
  # ===========================================================
  responses:
    BadRequest:
      description: Malformed request or invalid parameter.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    Unauthorized:
      description: Missing or invalid authentication.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    Forbidden:
      description: Authenticated but not permitted.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    NotFound:
      description: Resource does not exist or has been trashed.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    Conflict:
      description: State conflict.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    ContentConflict:
      description: >-
        content_conflict — the body was saved elsewhere since the request's
        recording.content_version; nothing was written. Rebase the edit onto
        the returned content_version (or reload) and save again.
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: "#/components/schemas/ProblemDetails"
              - type: object
                required: [content_version]
                properties:
                  content_version: { $ref: "#/components/schemas/ContentVersion" }
    UnprocessableContent:
      description: Validation failed.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    ContentTooLarge:
      description: Request body exceeds the endpoint's size cap (code payload_too_large).
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    StorageRedirect:
      description: Redirect to the file bytes (a short-lived storage URL, or a same-origin proxy path).
      headers:
        Location:
          schema: { type: string }
    TooManyRequests:
      description: Rate limit exceeded.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    OAuthError:
      description: OAuth error (RFC 6749 shape, application/json).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/OAuthError"

  # ===========================================================
  # Security schemes
  # ===========================================================
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key or OAuth access token passed as `Authorization: Bearer <token>`."
    # The same Bearer tokens, described as the OAuth 2.1 flow that issues
    # them (docs/api-conventions.md §12). Clients discover it from
    # /.well-known/oauth-authorization-server and register through DCR.
    oauth2:
      type: oauth2
      description: |
        OAuth 2.1 authorization code flow with PKCE (S256 only), for MCP
        clients acting as an agent. Register the client first
        (`POST /auth_api/register`), then send the browser to the consent
        page `https://qunli.co/auth/connect` with the authorization request
        (`response_type=code`, `client_id`, `redirect_uri`, `state`,
        `code_challenge`, `code_challenge_method=S256`,
        `resource=https://qunli.co/mcp`, `scope=mcp`); a person signs in,
        picks a team and one of the agents they manage, and approves. Exchange
        the code at `POST /auth_api/token`. Tokens are issued for the MCP
        server (`resource` must be `https://qunli.co/mcp`) and act as that
        agent in that team; the REST API accepts them the same way as an API
        key (`Authorization: Bearer <access_token>`). Access tokens last one
        hour; refresh tokens rotate on every use.
      flows:
        authorizationCode:
          authorizationUrl: https://qunli.co/auth/connect
          tokenUrl: https://qunli.co/auth_api/token
          refreshUrl: https://qunli.co/auth_api/token
          scopes:
            mcp: Everything the agent may do in the team it was authorized for (the only scope).
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-Api-Key
      description: "API key passed as `X-Api-Key: <token>`."
    oauthClientBasic:
      type: http
      scheme: basic
      description: |
        OAuth client authentication at /auth_api/token and /auth_api/revoke
        for confidential clients (`client_secret_basic`): HTTP Basic with the
        client_id and client_secret. Public clients (MCP clients) send
        `client_id` in the form body instead, and confidential ones may send
        `client_secret` there (`client_secret_post`).
    sessionCookie:
      type: apiKey
      in: cookie
      name: _qunli_session_v2
      description: Browser session (rodauth login). Only the consent endpoints document it.

security:
  - bearerAuth: []
  - apiKeyHeader: []
  - oauth2: [mcp]
