openapi: 3.1.0
info:
  title: AgentHub Internal API
  version: 0.1.0
  description: |
    AgentHub 内部契约。本文件与 `api_design.md` 互为契约的两种表达。

    规范力口径（ADR-0039，2026-07-04）：
    - 本文件是**前端类型生成的规范源**——CI `frontend-contract-drift` job 的
      `generate:check` 对其生成子集做 BLOCKING 零漂移校验。
    - **实装 API 面的权威记录是 FastAPI 导出**（`cd backend && uv run python
      scripts/export_openapi.py`）；本文件未被前端生成链消费的部分为设计意向
      （non-normative），允许领先或滞后于实装。全量路径 reconcile 为
      informational（`scripts/reconcile_openapi.py`），不做硬对账。

    - 角色分组前缀：`/api/v1/builder/*` `/api/v1/operator/*` `/api/v1/app/*` `/api/v1/public/*` `/api/v1/internal/*` `/api/v1/webhook/*`
      （注意：实装运营端当前使用 `/api/v1/ops/*` 前缀，与本文件的 `operator/*` 存在已登记漂移，统一留待文档治理任务包。）
    - 编辑前请阅读 `api_design.md` Section 03 / Section 11。
  contact:
    name: AgentHub 架构组
servers:
  - url: https://api.example.com
    description: 生产（占位，待 ops 确认）
  - url: https://staging.api.example.com
    description: Staging（占位）
  - url: http://localhost:8000
    description: 本地开发

tags:
  - name: builder.sop
    description: Builder — SOP 域
  - name: builder.agent
    description: Builder — Agent 域
  - name: builder.knowledge
    description: Builder — 知识库域（KnowledgeBase / KnowledgeDocument / KnowledgeChunk / 调试检索）
  - name: builder.model
    description: Builder — Model Registry（模型组 / 模型注册 / 路由；实装 models.py，Tier 0 T0-3 补齐）
  - name: builder.app-delivery
    description: Builder — 应用交付域（AppManifest / WorkflowBinding / ChannelConfig / PDKArtifact）
  - name: builder.pdk
    description: Builder — PDK 生成与下载
  - name: operator.tenant
    description: Operator — 租户生命周期（create / list / get / update / suspend / resume / reset_quota）
  - name: operator.user
    description: Operator — 租户内用户管理（invite / list / get / update / disable / enable / delete / reset_mfa / force_logout）
  - name: operator.role
    description: Operator — 租户级 RBAC 角色 CRUD（list / create / update / delete）
  - name: operator.user_role
    description: Operator — 用户—角色绑定（assign / revoke）
  - name: operator.points
    description: Operator — 长效积分批量发放 / ledger 审计 / 冻结排查 / 手工调账 / 对账触发
  - name: operator.points_card
    description: Operator — 积分卡模板与批量发行 / 列表 / 作废
  - name: operator.payment_order
    description: Operator — 支付订单跨用户检索 / 详情 / 退款
  - name: operator.dashboard
    description: Operator — 运营看板（overview / metrics / timeseries）
  - name: operator.ops_event
    description: Operator — OpsEvent 生命周期（list / get / acknowledge / assign / resolve / dismiss）
  - name: operator.ops_action
    description: Operator — OpsAction 流水查询
  - name: operator.ops_agent
    description: Operator — 运营 Agent 高风险处置审批（approve / reject）
  - name: saas.workbench
    description: |
      SaaS — 工作台域（C 端用户视角的 workflow_tasks 全生命周期）：
      submit/get/list/human_resume/cancel/events 6 个端点；
      task:read / task:run 权限闭环；详见 endpoints/saas-workbench.md。
  - name: billing.points
    description: Billing — 积分域（grant / tenant-grant / ledger / balance；T2 ADR-0044 + PP-0 ADR-0054 发布面）
  - name: billing.payment
    description: Billing — C 端充值下单 / 状态轮询 / 退款（payment_orders；ADR-0054 发布面）
  - name: saas.scene-profile
    description: SaaS — 场景画像（scene 扩展表 scene_profiles；PP-2 复用验证面）
  - name: saas.manifest
    description: SaaS — 应用元信息（占位）
  - name: public
    description: Public 端点
  - name: internal
    description: Internal 端点
  - name: webhook
    description: Webhook 回调
  - name: identity.me
    description: Identity — 当前用户 `/me`（JIT 首登 + 账号设置自助读写；ADR-0047）
  - name: identity.tenants
    description: Identity — 租户自助（个人→正式升级 / 当前租户 / 成员；IE-2）
  - name: identity.invites
    description: Identity — 租户邀请码创建与兑换（IE-2）
  - name: ops.tenants.admin
    description: Ops — 跨租户管理（list / get / patch / suspend / resume；IE-2）
  - name: ops.users
    description: Ops — 跨租户用户管理（list / disable / enable；IE-2）
  - name: conversation
    description: |
      Conversation — 会话域（CV1-2 / ADR-0049）：headless API-key + On-Behalf-Of
      委托通道（无 JWT）；开会话 / 发轮（SSE）/ 断连重拉 / 显式结束 / 历史消息分页。
      本轮 runner 为 mock echo，CV2 替换为 AgentScope 实时事件流（端点形状不变）。

security:
  - bearerAuth: []

paths:
  # ============================================================
  # 7.1 Builder — SOP 域
  # 详情见 endpoints/builder-sop.md
  # ============================================================
  /api/v1/builder/sops:
    get:
      operationId: builder.sop.list
      tags: [builder.sop]
      summary: 列出当前租户的 SOP 主体
      x-rbac: [sop:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
        - in: query
          name: filter[project_id]
          schema: { type: string, format: uuid }
        - in: query
          name: filter[status]
          schema:
            type: string
            enum: [designing, debugging, published, disabled, archived]
        - in: query
          name: filter[tag]
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SopListResponse' }
        default:
          $ref: '#/components/responses/Error'
    post:
      operationId: builder.sop.create
      tags: [builder.sop]
      summary: 创建 SOP 主体
      x-rbac: [sop:create]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SopCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sop' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/sops/{sop_id}:
    parameters:
      - in: path
        name: sop_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: builder.sop.get
      tags: [builder.sop]
      summary: 获取 SOP 主体
      x-rbac: [sop:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: include
          schema: { type: string, example: current_version }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sop' }
        default:
          $ref: '#/components/responses/Error'
    patch:
      operationId: builder.sop.update
      tags: [builder.sop]
      summary: 更新 SOP 主体（部分字段）
      x-rbac: [sop:update]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SopUpdateRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sop' }
        default:
          $ref: '#/components/responses/Error'
    delete:
      operationId: builder.sop.delete
      tags: [builder.sop]
      summary: 软删除 SOP 主体
      x-rbac: [sop:delete]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: version
          required: true
          schema: { type: integer, minimum: 0 }
      responses:
        '204':
          description: No Content
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/sops/{sop_id}/publish:
    parameters:
      - in: path
        name: sop_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: builder.sop.publish
      tags: [builder.sop]
      summary: 发布 SOP 版本
      x-rbac: [sop:publish]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SopPublishRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sop' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/sops/import:
    post:
      operationId: builder.sop.import
      tags: [builder.sop]
      summary: 导入 SOP 包（异步）
      x-rbac: [sop:create]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: true
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, project_id]
              properties:
                file:
                  type: string
                  format: binary
                project_id:
                  type: string
                  format: uuid
                conflict_strategy:
                  type: string
                  enum: [skip, overwrite, rename]
                  default: rename
      responses:
        '202':
          description: Accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncTaskAccepted' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/sops/{sop_id}/export:
    parameters:
      - in: path
        name: sop_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: builder.sop.export
      tags: [builder.sop]
      summary: 导出 SOP 包
      x-rbac: [sop:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: include_versions
          schema:
            type: string
            enum: [current, all, published]
            default: current
        - in: query
          name: format
          schema:
            type: string
            enum: [json, zip]
            default: json
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SopExportPackage' }
            application/zip:
              schema:
                type: string
                format: binary
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/sop-versions:
    get:
      operationId: builder.sop.version.list
      tags: [builder.sop]
      summary: 列出指定 SOP 的版本（按 version 降序；Tier 1 T1-1 GAP-T1-1-①）
      x-rbac: [sop:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: sop_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SopVersion' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/sop-versions/{version_id}:
    parameters:
      - in: path
        name: version_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: builder.sop.version.get
      tags: [builder.sop]
      summary: 获取单个 SOP 版本（含 workflow_spec；画布回读前提，Tier 1 T1-1 GAP-T1-1-②）
      x-rbac: [sop:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SopVersion' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # 7.2 Builder — Agent 域
  # 详情见 endpoints/builder-agent.md
  # ============================================================
  /api/v1/builder/agents:
    get:
      operationId: builder.agent.list
      tags: [builder.agent]
      summary: 列出当前租户的 Agent
      x-rbac: [agent:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
        - in: query
          name: project_id
          schema: { type: string, format: uuid }
        - in: query
          name: category
          schema:
            type: string
            enum: [builder, runtime, system, bench]
        - in: query
          name: agent_type
          description: CU3-1（ADR-0053 D6）——按 Agent 类型过滤
          schema:
            type: string
            enum: [sop_node, conversation]
        - in: query
          name: status
          description: >-
            CU3-1（ADR-0053 D6）——目录生命周期过滤。缺省只返回 `active`
            （不传即旧行为 + 过滤归档）；`all` 同时返回归档项。
          schema:
            type: string
            enum: [active, archived, all]
            default: active
        - in: query
          name: filter[model_group_id]
          schema: { type: string, format: uuid }
        - in: query
          name: filter[tag]
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentListResponse' }
        default:
          $ref: '#/components/responses/Error'
    post:
      operationId: builder.agent.register
      tags: [builder.agent]
      summary: 注册 Agent
      x-rbac: [agent:create]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentRegisterRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Agent' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/agents/{agent_id}:
    parameters:
      - in: path
        name: agent_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: builder.agent.get
      tags: [builder.agent]
      summary: 获取 Agent 详情
      x-rbac: [agent:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: include
          schema: { type: string, example: current_version,bindings }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Agent' }
        default:
          $ref: '#/components/responses/Error'
    patch:
      operationId: builder.agent.update
      tags: [builder.agent]
      summary: 修改 Agent 壳（CU3-1 / ADR-0053 D4）
      description: >-
        全部字段可选，未传的字段保持原值；空请求体 → 422。
        `agent_type` 仅在该 Agent 尚无任何版本时可改，已有版本 → 409
        `AGENT_TYPE_IMMUTABLE`（发布快照的形状由类型决定，事后翻转会让历史快照自相矛盾）。
        `status` 改回 `active` 即反归档。
      x-rbac: [agent:update]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentUpdateRequest' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Agent' }
        '404':
          description: 不存在或不属于当前租户（跨租户一律 404，防枚举）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '409':
          description: 已有版本的 Agent 尝试改 `agent_type`（`AGENT_TYPE_IMMUTABLE`）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '422':
          description: 空请求体（至少要给一个字段）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        default:
          $ref: '#/components/responses/Error'
    delete:
      operationId: builder.agent.delete
      tags: [builder.agent]
      summary: 归档（默认）或硬删 Agent（CU3-1 / ADR-0053 D2·D3）
      description: >-
        缺省语义是**归档**（软删）：`status` 置 `archived` 并返回 200，
        既有会话与积分流水的引用完整性不受影响。
        `hard=true` 时走硬删守卫——仅当「无任何已发布版本 ∧ 无任何会话引用 ∧
        不属于任何 Agent 团队」三条同时成立才物理删除并返回 204；
        任一不满足 → 409 `AGENT_DELETE_CONFLICT`，`data.conflicts` 指明具体类别。
      x-rbac: [agent:delete]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: hard
          description: true 时尝试物理删除（须满足三条件守卫），否则归档
          schema: { type: boolean, default: false }
      responses:
        '200':
          description: 已归档（默认语义）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Agent' }
        '204':
          description: 已物理删除（硬删守卫三条件全满足）
        '404':
          description: 不存在或不属于当前租户（跨租户一律 404，防枚举）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '409':
          description: >-
            硬删守卫拒绝（`AGENT_DELETE_CONFLICT`）；`data.conflicts` 为
            `published_versions` / `conversations` / `team_memberships` 的子集
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentDeleteConflictResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/agents/{agent_id}/skills/{skill_id}:
    parameters:
      - in: path
        name: agent_id
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: skill_id
        required: true
        schema: { type: string, format: uuid }
    put:
      operationId: builder.agent.bind_skill
      tags: [builder.agent]
      summary: 绑定 Skill 到 Agent 草稿版本
      x-rbac: [agent:bind]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentSkillBindingUpsertRequest' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentSkillBinding' }
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentSkillBinding' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/agents/{agent_id}/tools/{tool_id}:
    parameters:
      - in: path
        name: agent_id
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: tool_id
        required: true
        schema: { type: string, format: uuid }
    put:
      operationId: builder.agent.bind_tool
      tags: [builder.agent]
      summary: 绑定 Tool 到 Agent 草稿版本
      x-rbac: [agent:bind]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentToolBindingUpsertRequest' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentToolBinding' }
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentToolBinding' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/agents/{agent_id}/knowledge-bases/{kb_id}:
    parameters:
      - in: path
        name: agent_id
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: kb_id
        required: true
        schema: { type: string, format: uuid }
    put:
      operationId: builder.agent.bind_knowledge
      tags: [builder.agent]
      summary: 绑定知识库到 Agent 草稿版本
      x-rbac: [agent:bind]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentKnowledgeBindingUpsertRequest' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentKnowledgeBinding' }
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentKnowledgeBinding' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/agents/{agent_id}/publish:
    parameters:
      - in: path
        name: agent_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: builder.agent.publish
      tags: [builder.agent]
      summary: 发布 Agent 草稿版本
      x-rbac: [agent:publish]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentPublishRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Agent' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/agents/{agent_id}/versions:
    parameters:
      - in: path
        name: agent_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: builder.agent.version.list
      tags: [builder.agent]
      summary: 列出 Agent 的已发布版本时间线（按 version 升序；Tier 1 T1-1 GAP-T1-1-③）
      x-rbac: [agent:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/AgentVersion' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/agent-teams:
    get:
      operationId: builder.agent.team.list
      tags: [builder.agent]
      summary: 列出项目下的 Agent 团队（画布 multi_agent 节点下拉；Tier 1 T1-1 GAP-T1-1-④）
      x-rbac: [agent:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: project_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/AgentTeam' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # 7.4 Builder — 应用交付域
  # 详情见 endpoints/builder-app-delivery.md
  # ============================================================
  /api/v1/builder/manifests:
    get:
      operationId: builder.manifest.list
      tags: [builder.app-delivery]
      summary: 列出当前租户的 AppManifest
      x-rbac: [manifest:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
        - in: query
          name: filter[project_id]
          schema: { type: string, format: uuid }
        - in: query
          name: filter[status]
          schema:
            type: string
            enum: [draft, published, deprecated, archived]
        - in: query
          name: filter[app_id]
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AppManifestListResponse' }
        default:
          $ref: '#/components/responses/Error'
    post:
      operationId: builder.manifest.create
      tags: [builder.app-delivery]
      summary: 创建 AppManifest 草稿
      x-rbac: [manifest:create]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AppManifestCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AppManifest' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/manifests/{manifest_id}:
    parameters:
      - in: path
        name: manifest_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: builder.manifest.get
      tags: [builder.app-delivery]
      summary: 获取 AppManifest 详情
      x-rbac: [manifest:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: include
          description: 逗号分隔，可选 `bindings,channels,latest_pdk`
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AppManifest' }
        default:
          $ref: '#/components/responses/Error'
    patch:
      operationId: builder.manifest.update
      tags: [builder.app-delivery]
      summary: 修改 draft 状态的 AppManifest
      x-rbac: [manifest:update]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AppManifestUpdateRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AppManifest' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/manifests/{manifest_id}/publish:
    post:
      operationId: builder.manifest.publish
      tags: [builder.app-delivery]
      summary: 发布 AppManifest（同步切换 + PDK 异步生成）
      x-rbac: [manifest:publish]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: true
      x-billing: false
      parameters:
        - in: path
          name: manifest_id
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AppManifestPublishRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AppManifestPublishResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/manifests/{manifest_id}/deprecate:
    post:
      operationId: builder.manifest.deprecate
      tags: [builder.app-delivery]
      summary: 单独下架已发布 AppManifest
      x-rbac: [manifest:deprecate]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: path
          name: manifest_id
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AppManifestDeprecateRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AppManifest' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/manifests/{manifest_id}/workflow-bindings/{feature_key}:
    parameters:
      - in: path
        name: manifest_id
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: feature_key
        required: true
        schema:
          type: string
          minLength: 3
          maxLength: 64
          pattern: '^[a-z0-9_]+$'
    put:
      operationId: builder.manifest.bind_workflow
      tags: [builder.app-delivery]
      summary: 绑定 / 更新 frontend feature key 与 SOPVersion 的映射
      x-rbac: [manifest:update]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorkflowBindingUpsertRequest' }
      responses:
        '200':
          description: 更新已有绑定
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowBinding' }
        '201':
          description: 首次创建
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowBinding' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/manifests/{manifest_id}/channels/{channel}:
    parameters:
      - in: path
        name: manifest_id
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: channel
        required: true
        schema:
          type: string
          enum: [web, wechat_mini, android, ios]
    put:
      operationId: builder.manifest.configure_channel
      tags: [builder.app-delivery]
      summary: 配置 / 更新某端型差异化参数
      x-rbac: [manifest:update]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChannelConfigUpsertRequest' }
      responses:
        '200':
          description: 更新已有配置
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChannelConfig' }
        '201':
          description: 首次创建
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChannelConfig' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/manifests/{manifest_id}/pdk:generate:
    post:
      operationId: builder.pdk.generate
      tags: [builder.pdk]
      summary: 主动触发 PDKArtifact 生成
      x-rbac: [pdk:generate]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: true
      x-billing: false
      parameters:
        - in: path
          name: manifest_id
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PdkGenerateRequest' }
      responses:
        '202':
          description: Accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PdkGenerateAccepted' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/manifests/{manifest_id}/pdk:
    get:
      operationId: builder.pdk.list
      tags: [builder.pdk]
      summary: 列出指定 Manifest 的 PDKArtifact
      x-rbac: [pdk:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: path
          name: manifest_id
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
        - in: query
          name: filter[status]
          schema:
            type: string
            enum: [generating, ready, failed, deprecated]
        - in: query
          name: filter[pdk_version]
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PdkArtifactListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/manifests/{manifest_id}/pdk/{pdk_version}/download-url:
    get:
      operationId: builder.pdk.get_download_url
      tags: [builder.pdk]
      summary: 签发 PDK 短期下载链接
      x-rbac: [pdk:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: path
          name: manifest_id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: pdk_version
          required: true
          schema: { type: string }
        - in: query
          name: expires_in_seconds
          schema:
            type: integer
            minimum: 60
            maximum: 86400
            default: 86400
        - in: query
          name: disposition
          schema:
            type: string
            enum: [attachment, inline]
            default: attachment
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PdkDownloadUrlResponse' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # 7.3 Builder — 知识库域
  # 详情见 endpoints/builder-knowledge.md
  # ============================================================
  /api/v1/builder/knowledge-bases:
    post:
      operationId: builder.knowledge.create_kb
      summary: 创建知识库
      x-rbac: [knowledge:create]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      tags: [builder.knowledge]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/KnowledgeBaseCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeBase' }
        default:
          $ref: '#/components/responses/Error'
    get:
      operationId: builder.knowledge.list_kb
      summary: 知识库列表
      x-rbac: [knowledge:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      tags: [builder.knowledge]
      parameters:
        - in: query
          name: filter[project_id]
          schema: { type: string, format: uuid }
        - in: query
          name: filter[scope]
          schema: { type: string, enum: [project, tenant] }
        - in: query
          name: filter[status]
          schema: { type: string, enum: [active, building, disabled, archived] }
        - in: query
          name: filter[embedding_model_id]
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeBaseListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/knowledge-bases/{kb_id}:
    parameters:
      - in: path
        name: kb_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: builder.knowledge.get_kb
      summary: 知识库详情
      x-rbac: [knowledge:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      tags: [builder.knowledge]
      parameters:
        - in: query
          name: include
          schema: { type: string, description: '逗号分隔；可选 stats' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeBase' }
        default:
          $ref: '#/components/responses/Error'
    patch:
      operationId: builder.knowledge.update_kb
      summary: 更新知识库
      x-rbac: [knowledge:update]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      tags: [builder.knowledge]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema: { $ref: '#/components/schemas/KnowledgeBaseUpdateRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeBase' }
        default:
          $ref: '#/components/responses/Error'
    delete:
      operationId: builder.knowledge.delete_kb
      summary: 删除知识库
      x-rbac: [knowledge:delete]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      tags: [builder.knowledge]
      parameters:
        - in: query
          name: version
          required: true
          schema: { type: integer, minimum: 0 }
        - in: query
          name: force
          schema: { type: boolean, default: false }
      responses:
        '204':
          description: No Content
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/knowledge-bases/{kb_id}/rebuild:
    parameters:
      - in: path
        name: kb_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: builder.knowledge.rebuild_kb
      summary: 触发知识库重建
      x-rbac: [knowledge:rebuild]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: true
      x-billing: false
      tags: [builder.knowledge]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/KnowledgeBaseRebuildRequest' }
      responses:
        '202':
          description: Accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncTaskAccepted' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/knowledge-bases/{kb_id}/documents:
    parameters:
      - in: path
        name: kb_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: builder.knowledge.upload_document
      summary: 上传 / 登记文档
      x-rbac: [knowledge:ingest]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: true
      x-billing: true
      tags: [builder.knowledge]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                title: { type: string }
                metadata: { type: string, description: 'JSON 字符串' }
                language: { type: string, description: 'BCP-47' }
          application/json:
            schema: { $ref: '#/components/schemas/KnowledgeDocumentRegisterRequest' }
      responses:
        '202':
          description: Accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeDocument' }
        default:
          $ref: '#/components/responses/Error'
    get:
      operationId: builder.knowledge.list_documents
      summary: 文档列表
      x-rbac: [knowledge:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      tags: [builder.knowledge]
      parameters:
        - in: query
          name: filter[status]
          schema:
            type: string
            enum: [pending, queued, parsing, chunking, embedding, completed, failed, obsolete]
        - in: query
          name: filter[source_type]
          schema:
            type: string
            enum: [upload, url, webpage, conversation_archive, api_sync]
        - in: query
          name: filter[file_type]
          schema: { type: string }
        - in: query
          name: filter[metadata]
          schema: { type: string, description: 'jsonb @> 子集匹配（URL 编码 JSON）' }
        - in: query
          name: q
          schema: { type: string, description: '标题前缀模糊匹配' }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeDocumentListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/knowledge-bases/{kb_id}/documents/{document_id}:
    parameters:
      - in: path
        name: kb_id
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: document_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: builder.knowledge.get_document
      summary: 文档详情
      x-rbac: [knowledge:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      tags: [builder.knowledge]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeDocument' }
        default:
          $ref: '#/components/responses/Error'
    delete:
      operationId: builder.knowledge.delete_document
      summary: 删除文档
      x-rbac: [knowledge:delete]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      tags: [builder.knowledge]
      parameters:
        - in: query
          name: version
          required: true
          schema: { type: integer, minimum: 0 }
      responses:
        '204':
          description: No Content
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/knowledge-bases/{kb_id}/documents/{document_id}/retry:
    parameters:
      - in: path
        name: kb_id
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: document_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: builder.knowledge.retry_document
      summary: 重试失败 / 失效文档
      x-rbac: [knowledge:ingest]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: true
      x-billing: true
      tags: [builder.knowledge]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/KnowledgeDocumentRetryRequest' }
      responses:
        '202':
          description: Accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeDocument' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/knowledge-bases/{kb_id}/documents/{document_id}/chunks:
    parameters:
      - in: path
        name: kb_id
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: document_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: builder.knowledge.list_chunks
      summary: 分块列表（调试预览）
      x-rbac: [knowledge:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      tags: [builder.knowledge]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - in: query
          name: include_content
          schema: { type: boolean, default: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeChunkListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/knowledge-bases/{kb_id}/search:
    parameters:
      - in: path
        name: kb_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: builder.knowledge.search
      summary: 调试态检索
      x-rbac: [knowledge:debug]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: true
      tags: [builder.knowledge]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/KnowledgeSearchRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KnowledgeSearchResponse' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # 7.3b Builder — Model Registry（模型组 / 模型注册 / 路由，M-A02）
  # 实装权威：backend/app/builder/api/v1/endpoints/models.py（ADR-0039）
  # Tier 0 GAP-3 契约补齐（T0-3，方案 a）：GET /models、GET /routes 只读列表
  # ============================================================
  /api/v1/builder/model-groups:
    post:
      operationId: builder.model.create_group
      tags: [builder.model]
      summary: 创建模型组（租户 scoped）
      x-rbac: ['builder:model:write']
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ModelGroupCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelGroup' }
        default:
          $ref: '#/components/responses/Error'
    get:
      operationId: builder.model.list_groups
      tags: [builder.model]
      summary: 列出当前租户的模型组
      x-rbac: ['builder:model:read']
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ModelGroup' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/models:
    post:
      operationId: builder.model.register
      tags: [builder.model]
      summary: 注册模型（API key 仅落 vault 引用）
      x-rbac: ['builder:model:write']
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ModelRegisterRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Model' }
        default:
          $ref: '#/components/responses/Error'
    get:
      operationId: builder.model.list_models
      tags: [builder.model]
      summary: 列出当前租户已注册模型（可按模型组过滤；租户级，无 project 维度）
      x-rbac: ['builder:model:read']
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: group_id
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Model' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/models/{model_id}:
    delete:
      operationId: builder.model.delete
      tags: [builder.model]
      summary: 删除已注册模型（被路由/知识库引用时 409 MODEL_IN_USE）
      description: |
        用于纠正注册错误的模型（如模型类型选错）：`uk_models_tenant_group_name`
        使同组同名重复注册返回 409 MODEL_NAME_CONFLICT，需先删除旧行再重新注册。
        模型仍被 route（primary/fallback）或知识库 embedding 引用时返回
        409 MODEL_IN_USE，并在 `data.blockers` 列出阻塞项。
      x-rbac: ['builder:model:write']
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: path
          name: model_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK（Envelope data 为 null）
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/routes:
    post:
      operationId: builder.model.set_route
      tags: [builder.model]
      summary: 配置模型路由（primary + 可选 fallback，须同组）
      x-rbac: ['builder:model:write']
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ModelRouteRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelRoute' }
        default:
          $ref: '#/components/responses/Error'
    get:
      operationId: builder.model.list_routes
      tags: [builder.model]
      summary: 列出当前租户的模型路由（可按模型组过滤）
      x-rbac: ['builder:model:read']
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: group_id
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ModelRoute' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/routes/{route_id}:
    delete:
      operationId: builder.model.delete_route
      tags: [builder.model]
      summary: 删除模型路由（解除对 primary/fallback 模型的引用）
      description: |
        删除后该模型组暂时无路由，其原 primary/fallback 模型不再被引用，
        因而可以被 `builder.model.delete` 删除。
      x-rbac: ['builder:model:write']
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: path
          name: route_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK（Envelope data 为 null）
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/model-vendors:
    get:
      operationId: builder.model.list_vendors
      tags: [builder.model]
      summary: 列出生效中的 vendor→网关映射（租户 > env 覆盖 > 内置；MR1-1/MR2-1）
      description: |
        返回运行时路由实际使用的 vendor 解析结果。注册表单的 API URL 仅用于
        注册时连通性探针（不落库）；运行时地址只由 vendor 决定。本端点让
        UI 的 vendor 下拉以真实可解析集合为数据源，杜绝选出运行时不可用
        的 vendor（如 custom/anthropic）。env `MODEL_VENDOR_API_BASE` 覆盖
        优先于内置表并以 `source=env_override` 标注；MR2-1 起租户注册行
        置顶（source=tenant，携带行 id），同名内置项被遮蔽。
      x-rbac: ['builder:model:read']
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/ModelVendor' }
        default:
          $ref: '#/components/responses/Error'
    post:
      operationId: builder.model.create_vendor
      tags: [builder.model]
      summary: 注册租户 vendor（网关地址 + 协议族；MR2-1，ADR-0057）
      description: |
        覆盖 env/内置解析（地址与协议族同源整条覆盖）。api_base 受 SSRF 门
        （拒绝私网/环回/链路本地）；code 小写且 `^[a-z0-9][a-z0-9-]{0,63}$`，
        允许与内置同名（覆盖为特性）；stt/tts 模型注册要求 vendor 生效协议族
        为 dashscope（唯一已实现线协议）。
      x-rbac: ['builder:model:write']
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ModelVendorRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelVendorRow' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/model-vendors/{vendor_id}:
    patch:
      operationId: builder.model.update_vendor
      tags: [builder.model]
      summary: 部分更新租户 vendor 行（code 不可变；MR2-1）
      x-rbac: ['builder:model:write']
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: path
          name: vendor_id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ModelVendorUpdateRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelVendorRow' }
        default:
          $ref: '#/components/responses/Error'
    delete:
      operationId: builder.model.delete_vendor
      tags: [builder.model]
      summary: 删除租户 vendor 行（仍有模型引用时 409；MR2-1）
      x-rbac: ['builder:model:write']
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: path
          name: vendor_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK（Envelope data 为 null）
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # 7.7 Operator — 运营看板与运营 Agent
  # 详情见 endpoints/operator-dashboard.md
  # ============================================================
  /api/v1/operator/dashboard/overview:
    get:
      operationId: operator.dashboard.overview
      tags: [operator.dashboard]
      summary: 运营首屏全局健康度卡片集合
      x-rbac: [ops:dashboard_read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
        - in: query
          name: compare_with
          schema:
            type: string
            enum: [previous_period]
      responses:
        '200':
          description: OK
          headers:
            Cache-Control:
              schema: { type: string, example: 'max-age=30' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DashboardOverviewResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/dashboard/metrics:
    get:
      operationId: operator.dashboard.metrics
      tags: [operator.dashboard]
      summary: 批量拉取离散指标统计值
      x-rbac: [ops:dashboard_read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: metric
          required: true
          schema:
            type: array
            items: { type: string }
          style: form
          explode: true
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
        - in: query
          name: dimensions
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DashboardMetricsResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/dashboard/timeseries:
    get:
      operationId: operator.dashboard.timeseries
      tags: [operator.dashboard]
      summary: 单指标时间序列点列
      x-rbac: [ops:dashboard_read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: metric
          required: true
          schema: { type: string }
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
        - in: query
          name: granularity
          schema:
            type: string
            enum: [5m, 15m, 1h, 1d]
            default: 1h
        - in: query
          name: filter[project_id]
          schema: { type: string, format: uuid }
        - in: query
          name: filter[agent_id]
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DashboardTimeseriesResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/ops-events:
    get:
      operationId: operator.ops_event.list
      tags: [operator.ops_event]
      summary: 分页查询 OpsEvent 列表
      x-rbac: [ops:event_read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
        - in: query
          name: filter[status]
          schema:
            type: array
            items:
              type: string
              enum: [open, in_progress, resolved, dismissed, suppressed]
          style: form
          explode: true
        - in: query
          name: filter[severity]
          schema:
            type: array
            items:
              type: string
              enum: [info, warn, error, critical]
          style: form
          explode: true
        - in: query
          name: filter[category]
          schema: { type: string }
        - in: query
          name: filter[assignee_id]
          schema: { type: string, format: uuid }
        - in: query
          name: filter[unassigned]
          schema: { type: boolean }
        - in: query
          name: filter[sla_breaching_in]
          schema:
            type: string
            description: 'ISO 8601 duration（如 PT30M）'
        - in: query
          name: filter[fingerprint]
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OpsEventListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/ops-events/{event_id}:
    parameters:
      - in: path
        name: event_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: operator.ops_event.get
      tags: [operator.ops_event]
      summary: 获取 OpsEvent 详情
      x-rbac: [ops:event_read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: include
          schema:
            type: string
            description: '逗号分隔；可选 actions / source_events'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OpsEvent' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/ops-events/{event_id}/acknowledge:
    parameters:
      - in: path
        name: event_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: operator.ops_event.acknowledge
      tags: [operator.ops_event]
      summary: 认领 OpsEvent
      x-rbac: [ops:event_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OpsEventAcknowledgeRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OpsEvent' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/ops-events/{event_id}/assign:
    parameters:
      - in: path
        name: event_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: operator.ops_event.assign
      tags: [operator.ops_event]
      summary: 分派 OpsEvent
      x-rbac: [ops:event_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OpsEventAssignRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OpsEvent' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/ops-events/{event_id}/resolve:
    parameters:
      - in: path
        name: event_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: operator.ops_event.resolve
      tags: [operator.ops_event]
      summary: 标记 OpsEvent 已解决
      x-rbac: [ops:event_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: 高风险，需 X-MFA-Token
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OpsEventResolveRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OpsEvent' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/ops-events/{event_id}/dismiss:
    parameters:
      - in: path
        name: event_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: operator.ops_event.dismiss
      tags: [operator.ops_event]
      summary: 标记 OpsEvent 误报 / 不再处理
      x-rbac: [ops:event_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: 严重度 ≥ error 时需 X-MFA-Token
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OpsEventDismissRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OpsEvent' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/ops-actions:
    get:
      operationId: operator.ops_action.list
      tags: [operator.ops_action]
      summary: 分页查询 OpsAction 流水
      x-rbac: [ops:action_read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
        - in: query
          name: filter[event_id]
          schema: { type: string, format: uuid }
        - in: query
          name: filter[actor_type]
          schema:
            type: string
            enum: [auto, human, webhook]
        - in: query
          name: filter[action_type]
          schema: { type: string }
        - in: query
          name: filter[outcome]
          schema:
            type: array
            items:
              type: string
              enum: [pending, success, failure, skipped, cancelled]
          style: form
          explode: true
        - in: query
          name: filter[requires_mfa]
          schema: { type: boolean }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OpsActionListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/ops-actions/{action_id}:
    parameters:
      - in: path
        name: action_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: operator.ops_action.get
      tags: [operator.ops_action]
      summary: 获取 OpsAction 详情
      x-rbac: [ops:action_read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OpsAction' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/ops-actions/{action_id}/approve:
    parameters:
      - in: path
        name: action_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: operator.ops_agent.approve_action
      tags: [operator.ops_agent]
      summary: 审批通过 OpsAction
      x-rbac: [ops:action_approve]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: 高风险，需 X-MFA-Token；30 分钟失效窗口
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OpsActionApproveRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OpsAction' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/ops-actions/{action_id}/reject:
    parameters:
      - in: path
        name: action_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: operator.ops_agent.reject_action
      tags: [operator.ops_agent]
      summary: 拒绝 OpsAction 自动处置
      x-rbac: [ops:action_approve]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: 高风险，需 X-MFA-Token
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OpsActionRejectRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OpsAction' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # 7.8 Operator — 积分与支付（长效积分 / 积分卡 / 支付订单）
  # 详情见 endpoints/operator-points.md
  # ============================================================
  /api/v1/operator/points/long-term/batches:
    post:
      operationId: operator.points.issue_long_term_batch
      tags: [operator.points]
      summary: 长效积分批量发放
      x-rbac: [ops:points_grant]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: true
      x-billing: false
      description: |
        Idempotency-Key + `batch_ref` 双唯一锁；items.length > 50 强制 202 异步路径。
        落 `long_term_points_batches.source = 'manual_grant'`。
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IssueLongTermBatchRequest' }
      responses:
        '200':
          description: 同步完成
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssueLongTermBatchResponse' }
        '202':
          description: 异步任务受理
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncTaskAccepted' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/points/ledger:
    get:
      operationId: operator.points.list_ledger
      tags: [operator.points]
      summary: 跨用户审计 points_ledger
      x-rbac: [ops:points_audit]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: user_id
          schema: { type: string, format: uuid }
        - in: query
          name: bucket
          schema:
            type: string
            enum: [activity, recharge, longterm]
        - in: query
          name: change_type
          schema:
            type: string
            enum: [grant, deduct, freeze, unfreeze, expire, refund, adjust]
        - in: query
          name: created_from
          schema: { type: string, format: date-time }
        - in: query
          name: created_to
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LedgerListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/points/freezes:
    get:
      operationId: operator.points.list_freezes
      tags: [operator.points]
      summary: 列出未释放冻结
      x-rbac: [ops:points_audit]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: user_id
          schema: { type: string, format: uuid }
        - in: query
          name: status
          schema:
            type: string
            enum: [active, released, partially_released, expired]
            default: active
        - in: query
          name: ref_task_id
          schema: { type: string, format: uuid }
        - in: query
          name: aged_over_seconds
          schema: { type: integer, minimum: 0 }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FreezeListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/points/manual-adjust:
    post:
      operationId: operator.points.manual_adjust
      tags: [operator.points]
      summary: 手工调账（高风险）
      x-rbac: [ops:points_grant]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: 需 X-MFA-Token；扣减按 FIFO；强制写 audit_logs
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ManualAdjustRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManualAdjustResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/points/reconcile:
    post:
      operationId: operator.points.reconcile
      tags: [operator.points]
      summary: 触发对账任务
      x-rbac: [ops:reconcile_run]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: true
      x-billing: false
      description: dry_run=false 需 X-MFA-Token；同 (scope, window) 24h 内复用
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReconcileRequest' }
      responses:
        '200':
          description: 同步返回（dry_run=true 或缓存命中）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReconcileResponse' }
        '202':
          description: 异步对账任务受理
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncTaskAccepted' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/points-cards/templates:
    post:
      operationId: operator.points_card.create
      tags: [operator.points_card]
      summary: 创建积分卡模板
      x-rbac: [ops:points_card_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: 仅创建模板，不生成卡实体
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PointsCardTemplateCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PointsCardTemplate' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/points-cards:
    get:
      operationId: operator.points_card.list
      tags: [operator.points_card]
      summary: 卡实体列表（仅返回前缀，不含明文卡号）
      x-rbac: [ops:points_card_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: template_id
          schema: { type: string, format: uuid }
        - in: query
          name: status
          schema:
            type: string
            enum: [unused, redeemed, voided, expired]
        - in: query
          name: batch_id
          schema: { type: string, format: uuid }
        - in: query
          name: created_from
          schema: { type: string, format: date-time }
        - in: query
          name: created_to
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PointsCardListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/points-cards/batches:
    post:
      operationId: operator.points_card.issue_batch
      tags: [operator.points_card]
      summary: 批量发行积分卡（高风险）
      x-rbac: [ops:points_card_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: true
      x-billing: false
      description: 需 X-MFA-Token；明文卡号 ZIP 仅通过限时签名 URL（TTL ≤ 1h）返回
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PointsCardIssueBatchRequest' }
      responses:
        '202':
          description: 异步任务受理
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PointsCardIssueBatchResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/points-cards/{card_id}/void:
    parameters:
      - in: path
        name: card_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: operator.points_card.void
      tags: [operator.points_card]
      summary: 作废积分卡（高风险）
      x-rbac: [ops:points_card_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: 需 X-MFA-Token；仅 status='unused' 可作废
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PointsCardVoidRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PointsCard' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/payment-orders:
    get:
      operationId: operator.payment_order.list
      tags: [operator.payment_order]
      summary: 跨用户检索支付订单
      x-rbac: [ops:payment_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: user_id
          schema: { type: string, format: uuid }
        - in: query
          name: status
          schema:
            type: string
            enum: [pending, paid, failed, refunding, refunded, canceled]
        - in: query
          name: business_type
          schema:
            type: string
            enum: [recharge, points_card_purchase]
        - in: query
          name: channel
          schema: { type: string }
        - in: query
          name: created_from
          schema: { type: string, format: date-time }
        - in: query
          name: created_to
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentOrderListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/payment-orders/{order_id}:
    parameters:
      - in: path
        name: order_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: operator.payment_order.get
      tags: [operator.payment_order]
      summary: 支付订单详情（含状态机历史 + 关联 ledger + 退款记录）
      x-rbac: [ops:payment_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentOrderDetail' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/payment-orders/{order_id}/refund:
    parameters:
      - in: path
        name: order_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: operator.payment_order.refund
      tags: [operator.payment_order]
      summary: 发起退款（高风险）
      x-rbac: [ops:payment_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: true
      x-billing: false
      description: 需 X-MFA-Token；级联反向 ledger（recall_points 默认 true）
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentRefundRequest' }
      responses:
        '202':
          description: 异步退款任务受理
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentRefundResponse' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # 7.5 Operator — 租户与用户域
  # 详情见 endpoints/operator-tenant.md
  # ============================================================
  /api/v1/operator/tenants:
    post:
      operationId: operator.tenant.create
      tags: [operator.tenant]
      summary: 创建租户（高敏，平台级）
      x-rbac: [tenant:create]
      x-idempotent: true
      x-tenant-scope: global
      x-async: true
      x-billing: false
      x-mfa: required
      description: DB + Keycloak 双写 SAGA；需 platform_admin + X-MFA-Token；返回 202 + task_id
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TenantCreateRequest' }
      responses:
        '202':
          description: 异步受理（DB 已提交、Keycloak 同步中）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantCreateAcceptedResponse' }
        default:
          $ref: '#/components/responses/Error'
    get:
      operationId: operator.tenant.list
      tags: [operator.tenant]
      summary: 列出租户（平台级）
      x-rbac: [tenant:read]
      x-idempotent: true
      x-tenant-scope: global
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
        - in: query
          name: filter[status]
          schema: { type: string, enum: [active, suspended, deleted] }
        - in: query
          name: filter[keyword]
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/tenants/{tenant_id}:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    get:
      operationId: operator.tenant.get
      tags: [operator.tenant]
      summary: 租户详情（平台 / 租户双视图）
      x-rbac: [tenant:read, tenant:admin]
      x-idempotent: true
      x-tenant-scope: global|tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantDetail' }
        default:
          $ref: '#/components/responses/Error'
    patch:
      operationId: operator.tenant.update
      tags: [operator.tenant]
      summary: 修改租户元信息（JSON Merge Patch）
      x-rbac: [tenant:update, tenant:admin]
      x-idempotent: true
      x-tenant-scope: global|tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IfMatch'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema: { $ref: '#/components/schemas/TenantUpdateRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tenant' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/tenants/{tenant_id}/suspend:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    post:
      operationId: operator.tenant.suspend
      tags: [operator.tenant]
      summary: 暂停租户（高敏，平台级）
      x-rbac: [tenant:suspend]
      x-idempotent: true
      x-tenant-scope: global
      x-async: false
      x-billing: false
      x-mfa: required
      description: 需 X-MFA-Token；级联 Outbox `tenant.suspended`
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TenantSuspendRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tenant' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/tenants/{tenant_id}/resume:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    post:
      operationId: operator.tenant.resume
      tags: [operator.tenant]
      summary: 恢复租户（平台级）
      x-rbac: [tenant:suspend]
      x-idempotent: true
      x-tenant-scope: global
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tenant' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/tenants/{tenant_id}/quota:
    parameters:
      - $ref: '#/components/parameters/TenantIdPath'
    post:
      operationId: operator.tenant.reset_quota
      tags: [operator.tenant]
      summary: 调整租户配额
      x-rbac: [tenant:update]
      x-idempotent: true
      x-tenant-scope: global|tenant
      x-async: false
      x-billing: false
      description: 写入 `metadata_json.quota`；待 L3-D 配额模型对齐后冻结字段集合
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TenantQuotaResetRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tenant' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/users:
    post:
      operationId: operator.user.invite
      tags: [operator.user]
      summary: 邀请用户加入租户
      x-rbac: [tenant:user_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: DB + Keycloak 双写；邮件邀请链接 24h 有效
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UserInviteRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        default:
          $ref: '#/components/responses/Error'
    get:
      operationId: operator.user.list
      tags: [operator.user]
      summary: 用户列表
      x-rbac: [tenant:user_manage, audit:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
        - in: query
          name: filter[status]
          schema: { type: string, enum: [active, pending, disabled, deleted] }
        - in: query
          name: filter[role_code]
          schema: { type: string }
        - in: query
          name: filter[keyword]
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/users/{user_id}:
    parameters:
      - $ref: '#/components/parameters/UserIdPath'
    get:
      operationId: operator.user.get
      tags: [operator.user]
      summary: 用户详情
      x-rbac: [tenant:user_manage, audit:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: include
          schema: { type: string, enum: [roles, quota_usage, all] }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserDetail' }
        default:
          $ref: '#/components/responses/Error'
    patch:
      operationId: operator.user.update
      tags: [operator.user]
      summary: 修改用户基础信息（JSON Merge Patch）
      x-rbac: [tenant:user_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IfMatch'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema: { $ref: '#/components/schemas/UserUpdateRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        default:
          $ref: '#/components/responses/Error'
    delete:
      operationId: operator.user.delete
      tags: [operator.user]
      summary: 注销用户（高敏，软删除）
      x-rbac: [tenant:user_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      x-mfa: required
      description: 需 X-MFA-Token；存在活跃资源时返回 USER_HAS_ACTIVE_RESOURCES
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UserDeleteRequest' }
      responses:
        '204':
          description: No Content
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/users/{user_id}/disable:
    parameters:
      - $ref: '#/components/parameters/UserIdPath'
    post:
      operationId: operator.user.disable
      tags: [operator.user]
      summary: 停用用户
      x-rbac: [tenant:user_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UserDisableRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/users/{user_id}/enable:
    parameters:
      - $ref: '#/components/parameters/UserIdPath'
    post:
      operationId: operator.user.enable
      tags: [operator.user]
      summary: 启用用户
      x-rbac: [tenant:user_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/users/{user_id}/reset-mfa:
    parameters:
      - $ref: '#/components/parameters/UserIdPath'
    post:
      operationId: operator.user.reset_mfa
      tags: [operator.user]
      summary: 重置 MFA（高敏）
      x-rbac: [tenant:user_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      x-mfa: required
      description: 需 X-MFA-Token；写 Keycloak required_actions += CONFIGURE_TOTP
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserMfaResetResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/users/{user_id}/force-logout:
    parameters:
      - $ref: '#/components/parameters/UserIdPath'
    post:
      operationId: operator.user.force_logout
      tags: [operator.user]
      summary: 强制下线
      x-rbac: [tenant:user_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: Keycloak 会话撤销 + JWT 黑名单广播
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserForceLogoutResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/roles:
    get:
      operationId: operator.role.list
      tags: [operator.role]
      summary: 列出角色（系统 + 自定义）
      x-rbac: [tenant:role_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: scope
          schema: { type: string, enum: [all, system, custom], default: all }
        - in: query
          name: keyword
          schema: { type: string }
        - in: query
          name: page
          schema: { type: integer, minimum: 1, default: 1 }
        - in: query
          name: page_size
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - in: query
          name: order_by
          schema: { type: string, enum: [code, created_at], default: code }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RoleListResponse' }
        default:
          $ref: '#/components/responses/Error'
    post:
      operationId: operator.role.create
      tags: [operator.role]
      summary: 新建自定义角色
      x-rbac: [tenant:role_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RoleCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Role' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/roles/{role_id}:
    parameters:
      - $ref: '#/components/parameters/RoleIdPath'
    patch:
      operationId: operator.role.update
      tags: [operator.role]
      summary: 修改自定义角色（权限码全量替换）
      x-rbac: [tenant:role_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - $ref: '#/components/parameters/IfMatch'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema: { $ref: '#/components/schemas/RoleUpdateRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Role' }
        default:
          $ref: '#/components/responses/Error'
    delete:
      operationId: operator.role.delete
      tags: [operator.role]
      summary: 删除自定义角色（高危）
      x-rbac: [tenant:role_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      x-mfa: required
      description: 需 X-MFA-Token；user_roles 仍有引用时返回 ROLE_IN_USE
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/MfaToken'
      responses:
        '204':
          description: No Content
        default:
          $ref: '#/components/responses/Error'

  /api/v1/operator/users/{user_id}/roles/{role_id}:
    parameters:
      - $ref: '#/components/parameters/UserIdPath'
      - $ref: '#/components/parameters/RoleIdPath'
    put:
      operationId: operator.user_role.assign
      tags: [operator.user_role]
      summary: 分配角色（PUT 天然幂等）
      x-rbac: [tenant:role_assign]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RoleAssignRequest' }
      responses:
        '200':
          description: OK（含已存在的绑定）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserRoleAssignment' }
        default:
          $ref: '#/components/responses/Error'
    delete:
      operationId: operator.user_role.revoke
      tags: [operator.user_role]
      summary: 撤销角色绑定
      x-rbac: [tenant:role_assign]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      x-mfa: conditional
      description: 撤销 tenant_admin 时强制 X-MFA-Token；触发 LAST_TENANT_ADMIN 自我保护
      parameters:
        - $ref: '#/components/parameters/MfaToken'
      responses:
        '204':
          description: No Content
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # 7.10 SaaS User — 应用元信息域
  # 详情见 endpoints/saas-manifest.md
  # ============================================================
  /api/v1/app/manifest/handshake:
    get:
      operationId: saas.manifest.handshake
      tags: [saas.manifest]
      summary: SDK client.init() 期版本协商（Conformance Handshake）
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: false
      x-auth: session
      description: |
        登录态 SDK 在 `client.init()` 阶段调用；按 Manifest version、SDK 版本、RuntimeEvent Schema 版本三维校验，
        返回 compatible / compatible_with_warning / incompatible 以及一次性 session_token。
        与公共匿名版 public.manifest.get_runtime 形成对位，但本接口必须 JWT。
        判定矩阵见 L2-D §8.3；客户端实现见 L2-D §8.4。
      parameters:
        - in: header
          name: X-AgentHub-App-Id
          required: true
          schema: { type: string }
          description: 目标 Manifest 的稳定 app_id
        - in: header
          name: X-AgentHub-Manifest-Version
          required: true
          schema: { type: string }
          description: 客户端当前持有的 Manifest 版本号
        - in: header
          name: X-AgentHub-SDK-Name
          required: true
          schema: { type: string, enum: [sdk-web, sdk-wechat, sdk-react] }
        - in: header
          name: X-AgentHub-SDK-Version
          required: true
          schema:
            type: string
            pattern: '^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$'
          description: 语义化版本（MAJOR.MINOR.PATCH）
        - in: header
          name: X-AgentHub-Runtime-Event-Schema
          required: true
          schema: { type: integer, minimum: 1 }
        - in: header
          name: X-AgentHub-Frontend-Version
          required: false
          schema: { type: string }
          description: 前端业务版本号，仅作审计与可观测维度
      responses:
        '200':
          description: 协商完成（incompatible 仍走 200，由 status 字段判别）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManifestHandshakeResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/manifests:
    get:
      operationId: saas.manifest.list
      tags: [saas.manifest]
      summary: 登录态获取本租户已发布 App Manifest 目录（Starter 验证台 App 选择器数据源）
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: false
      x-auth: session
      description: |
        T6 Starter SaaS 验证台目录端点。仅返回当前 JWT 租户内 status=published 的 Manifest 瘦视图。
        含 project_id（方案 a，供积分余额/档案查询）；binding_summaries 含 estimated_points（自 billing_rules 派生）。
        不含 private_manifest_json / input_schema / output_schema——表单仍走 saas.manifest.get。
      parameters:
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - in: query
          name: offset
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SaasManifestCatalogPage' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/manifests/{app_id}:
    parameters:
      - in: path
        name: app_id
        required: true
        schema: { type: string }
        description: Manifest 业务标识
    get:
      operationId: saas.manifest.get
      tags: [saas.manifest]
      summary: 登录态获取 Manifest 运行期完整公开切片
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: false
      x-auth: session
      description: |
        与公共匿名版 public.manifest.get_runtime 是登录态对位；
        含 frontend_features / channel_config / feature_flags_resolved / grayscale / payment_methods_public 等。
        BFF 30s 缓存，按 (tenant_id, user_id, app_id, manifest_version, channel) 聚合键。
      parameters:
        - in: query
          name: manifest_version
          required: false
          schema: { type: string }
          description: 指定历史版本；仅允许 published 或仍在 90 天保留窗口内的 deprecated
        - in: query
          name: channel
          required: false
          schema: { type: string, enum: [web, wechat_mini, h5] }
        - in: query
          name: include
          required: false
          schema: { type: string }
          description: 逗号分隔扩展字段，可选 grayscale / payment_methods_public / feature_flags_resolved / event_subscription_policy
        - in: header
          name: X-AgentHub-App-Id
          required: true
          schema: { type: string }
        - in: header
          name: X-AgentHub-Manifest-Version
          required: true
          schema: { type: string }
        - in: header
          name: X-AgentHub-SDK-Name
          required: true
          schema: { type: string }
        - in: header
          name: X-AgentHub-SDK-Version
          required: true
          schema: { type: string }
        - in: header
          name: X-AgentHub-Runtime-Event-Schema
          required: true
          schema: { type: integer }
        - in: header
          name: X-AgentHub-Session-Token
          required: true
          schema: { type: string }
          description: 由 saas.manifest.handshake 颁发的一次性会话令牌
      responses:
        '200':
          description: OK
          headers:
            X-Cache:
              schema: { type: string, enum: [HIT, MISS] }
              description: BFF 缓存命中标记
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SaasManifestRuntime' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # 7.8 SaaS User — 工作台域
  # 详情见 endpoints/saas-workbench.md
  # ============================================================
  /api/v1/app/workflow-tasks:
    post:
      operationId: saas.workflow_task.submit
      tags: [saas.workbench]
      summary: 当前登录用户提交一次工作流任务
      x-rbac: [task:run]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: true
      x-billing: true
      description: |
        202 + task_id 模式；事务内冻结 manifest.estimated_points（freeze_records.status='active'）
        后写 workflow_tasks(status='created', version=1)，并发出 workflow_task.submitted RuntimeEvent。
        余额不足返回 402 POINTS_INSUFFICIENT；预算熔断返回 429 BUDGET_CIRCUIT_BROKEN。
        Idempotency-Key 24h 窗口；命中重放返回首次完整响应（不重复触发副作用）。
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/ManifestVersion'
        - $ref: '#/components/parameters/SdkName'
        - $ref: '#/components/parameters/SdkVersion'
        - $ref: '#/components/parameters/RuntimeEventSchema'
        - $ref: '#/components/parameters/SessionToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorkflowTaskSubmitRequest' }
      responses:
        '202':
          description: 已受理，调度器将异步拉起
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowTaskSubmitResponse' }
        '200':
          description: Idempotency-Key 命中已存在记录，原响应体重放
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowTaskSubmitResponse' }
        default:
          $ref: '#/components/responses/Error'
    get:
      operationId: saas.workflow_task.list
      tags: [saas.workbench]
      summary: 列出当前登录用户自己的任务（owner 视图）
      x-rbac: [task:read]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: false
      description: |
        游标分页；命中 idx_workflow_tasks_owner (tenant_id, user_id, submitted_at DESC)；
        filter[submitted_at_gte/_lt] 跨度 ≤ 90 天，超出返回 400 INVALID_FILTER。
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: 'filter[status]'
          required: false
          schema: { type: string }
          description: 逗号分隔；枚举 created/running/waiting_human/paused/succeeded/failed/cancelled/timeout
        - in: query
          name: 'filter[app_id]'
          required: false
          schema: { type: string }
        - in: query
          name: 'filter[channel]'
          required: false
          schema: { type: string }
          description: 逗号分隔；枚举 web/wechat_mini/android/ios/api
        - in: query
          name: 'filter[submitted_at_gte]'
          required: false
          schema: { type: string, format: date-time }
        - in: query
          name: 'filter[submitted_at_lt]'
          required: false
          schema: { type: string, format: date-time }
        - in: query
          name: sort
          required: false
          schema:
            type: string
            enum: [submitted_at, '-submitted_at']
            default: '-submitted_at'
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/ManifestVersion'
        - $ref: '#/components/parameters/SessionToken'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowTaskListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/workflow-tasks/{task_id}:
    parameters:
      - in: path
        name: task_id
        required: true
        schema: { type: string }
        description: workflow_tasks.id（前缀 wt_）；不存在或被 RLS 屏蔽 → 404 TASK_NOT_FOUND（防枚举）
    get:
      operationId: saas.workflow_task.get
      tags: [saas.workbench]
      summary: 查询当前登录用户自己的任务详情
      x-rbac: [task:read]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: include
          required: false
          schema: { type: string, default: 'summary' }
          description: 逗号分隔扩展字段；可选 summary / pause_point / item_tasks_count / last_event
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/ManifestVersion'
        - $ref: '#/components/parameters/SessionToken'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowTask' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/workflow-tasks/{task_id}/human-resume:
    parameters:
      - in: path
        name: task_id
        required: true
        schema: { type: string }
    post:
      operationId: saas.workflow_task.human_resume
      tags: [saas.workbench]
      summary: 人工节点接力（waiting_human → running）
      x-rbac: [task:run]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: true
      x-billing: false
      description: |
        Idempotency-Key + operation_id 双锁；CAS 条件：status='waiting_human' AND version=:expected_version。
        resume_payload 必须满足 pause_point.prompt_schema_ref 引用的 JSON Schema，否则返 422 RESUME_PAYLOAD_INVALID。
        已超 hard_deadline_at（默认 72h）返 410 TASK_HARD_TIMEOUT。
        被运营冻结（status='paused'）返 409 TASK_PAUSED_BY_OPERATOR。
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/ManifestVersion'
        - $ref: '#/components/parameters/SdkName'
        - $ref: '#/components/parameters/SdkVersion'
        - $ref: '#/components/parameters/RuntimeEventSchema'
        - $ref: '#/components/parameters/SessionToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorkflowTaskHumanResumeRequest' }
      responses:
        '202':
          description: 已受理，调度器在 checkpoint 上 resume
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowTaskHumanResumeResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/workflow-tasks/{task_id}/cancel:
    parameters:
      - in: path
        name: task_id
        required: true
        schema: { type: string }
    post:
      operationId: saas.workflow_task.cancel
      tags: [saas.workbench]
      summary: 取消任务（非终态 → cancelling → cancelled）
      x-rbac: [task:run]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: true
      x-billing: true
      description: |
        Idempotency-Key + operation_id 双锁；先 CAS 置 cancelling 瞬时态，再异步执行补偿钩子
        （unfreeze → 按 actual_points 已结部分按比例退款 → notify），最终 emit workflow_task.cancelled。
        终态（succeeded/failed/cancelled/timeout）拒重复返 409 TASK_TERMINAL；
        同 Idempotency-Key 命中已 cancelled 终态时按幂等返 200 而非 409。
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/ManifestVersion'
        - $ref: '#/components/parameters/SdkName'
        - $ref: '#/components/parameters/SdkVersion'
        - $ref: '#/components/parameters/RuntimeEventSchema'
        - $ref: '#/components/parameters/SessionToken'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorkflowTaskCancelRequest' }
      responses:
        '202':
          description: 已受理，补偿钩子异步执行
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowTaskCancelResponse' }
        '200':
          description: Idempotency-Key 命中已 cancelled 终态，原响应体重放
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowTaskCancelResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/workflow-tasks/{task_id}/events:
    parameters:
      - in: path
        name: task_id
        required: true
        schema: { type: string }
    get:
      operationId: saas.workflow_task.events
      tags: [saas.workbench]
      summary: 订阅运行期事件流（SSE 主路径 + 轮询兜底）
      x-rbac: [task:read]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: sse
      x-billing: false
      description: |
        Accept: text/event-stream → SSE；Accept: application/json → 轮询。
        终态事件（succeeded/failed/cancelled/timeout）发出后服务端主动关闭 SSE 连接。
        终态 ≥ 7 天的事件可能已归档（DETACH 分区），返回 410 EVENTS_ARCHIVED。
      parameters:
        - in: query
          name: since
          required: false
          schema: { type: string }
          description: 上次收到的 event_id；SSE 模式下作为 Last-Event-ID 兜底
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
          description: 仅轮询模式生效；SSE 模式忽略
        - in: query
          name: event_kinds
          required: false
          schema: { type: string }
          description: 逗号分隔；过滤事件名前缀，如 'workflow_task.,node.,tool.'
        - in: header
          name: Last-Event-ID
          required: false
          schema: { type: string }
          description: SSE 断线重连
        - $ref: '#/components/parameters/AppId'
        - $ref: '#/components/parameters/ManifestVersion'
        - $ref: '#/components/parameters/SessionToken'
      responses:
        '200':
          description: |
            SSE 模式 → text/event-stream（每条 event_id / event / data 帧）；
            轮询模式 → application/json
          content:
            text/event-stream:
              schema:
                type: string
                description: SSE 帧序列；每帧含 id / event / data 三个字段
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowTaskEventListResponse' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # 7.12 Conversation — 会话域（CV1-2 / ADR-0049）
  # headless API-key + On-Behalf-Of 委托通道；详见
  # docs/design/3.task/packages/CV1-2/TASK.md
  # ============================================================
  /api/v1/app/agents:
    get:
      operationId: app.agentCatalog.list
      tags: [conversation]
      summary: 用户侧会话 Agent 目录（CU3-2）——本租户已发布、可试用的 conversation Agent
      x-rbac: [conversation:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      security:
        - bearerAuth: []
      description: |
        JWT-only（ADR-0052 D1 双凭据仅覆盖 `/api/v1/app/conversations*`）：本路径不接纳
        `X-AgentHub-Api-Key` 委托通道，只带 Key 无 Bearer → 401；无凭据 → 401；
        缺 `conversation:read` → 403 `AUTH_SCOPE_INSUFFICIENT`。
        仅返回 `status=active ∧ active_version_id 非空 ∧ agent_type=conversation` 的 Agent；
        响应收敛为五字段——人设（persona/core）、工具绑定（tool_ids/bindings）、
        SOP 白名单、护栏细节属建设者侧配置，即使存在于发布快照也绝不出现在本响应
        （CU3-2 安全红线）。`points_per_turn` / `idle_timeout_seconds` 取自已发布版本
        快照 `config_snapshot_jsonb.conversation` 块。本期不分页：租户内已发布会话
        Agent 为人度量级；量起后按 Cursor/Limit 参数先例补。
      parameters:
        - in: query
          name: agent_type
          description: 本期唯一合法值 `conversation`（缺省即之）；其他值 → 422
          schema:
            type: string
            enum: [conversation]
            default: conversation
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConversationAgentCatalogResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/conversations:
    post:
      operationId: conversation.create
      tags: [conversation]
      summary: 开启会话（mock echo runner；CV2 替换 AgentScope 运行时，形状不变）
      x-rbac: [conversation:create]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: false
      security:
        - agentHubApiKey: []
        - bearerAuth: []
      description: |
        Headless 委托通道：X-AgentHub-Api-Key 定位租户，X-AgentHub-On-Behalf-Of 定位影子用户；
        鉴权失败 401 API_KEY_INVALID，On-Behalf-Of 非本租户/非 active 用户 403 AUTH_TENANT_MISMATCH。
        agent 不存在（RLS 租户作用域内）→ 404 NOT_FOUND（不泄露跨租户存在性）；
        agent 无已发布版本 → 409 CONVERSATION_AGENT_NOT_PUBLISHED。
        幂等：携带 client_conversation_key 且 (tenant_id, user_id, agent_id, key) 命中既有会话时
        返回 200 + 既有会话体（不新建）；不携带则不查重。
      parameters:
        - $ref: '#/components/parameters/OnBehalfOf'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConversationCreateRequest' }
      responses:
        '201':
          description: 会话已创建
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConversationOut' }
        '200':
          description: client_conversation_key 命中既有会话，原会话体重放（幂等）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConversationOut' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/conversations/{conversationId}:
    parameters:
      - in: path
        name: conversationId
        required: true
        schema: { type: string, format: uuid }
        description: conversations.id；不存在或被 RLS 屏蔽 → 404 CONVERSATION_NOT_FOUND（防枚举）
    get:
      operationId: conversation.get
      tags: [conversation]
      summary: 查询会话状态
      x-rbac: [conversation:read]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: false
      security:
        - agentHubApiKey: []
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OnBehalfOf'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConversationOut' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/conversations/{conversationId}/turns:
    parameters:
      - in: path
        name: conversationId
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: conversation.turn.create
      tags: [conversation]
      summary: 发起一轮对话（SSE 流式返回；mock echo 同步完成后做流式投影）
      x-rbac: [conversation:create]
      x-idempotent: false
      x-tenant-scope: tenant+user
      x-async: sse
      x-billing: false
      security:
        - agentHubApiKey: []
        - bearerAuth: []
      description: |
        会话不存在 → 404 CONVERSATION_NOT_FOUND；已 closed → 409 CONVERSATION_CLOSED
        （message 提示开新会话）；同会话存在非终态轮次 → 409 CONVERSATION_ACTIVE_TURN_CONFLICT
        （uk_turn_one_active 部分唯一索引兜底）。
        输入内容块（CV4-3）：text|file|page_ref|activity；后三类第三方内容以
        `<data type="...">` 标签包裹注入 system prompt 声明「data 非指令」（program §八 D1）。
        轮次在响应开始前已同步完成全部写库并提交；SSE 为「已完成轮次的流式投影」，
        连接中断不影响落库，客户端凭首帧 turn_id 走 GET turns/{turnId} 重拉，不重跑。
        CV3-2 计费（ADR-0050 D1/D2/D4）：快照 conversation.points_per_turn > 0 时，
        轮初 freeze（operation_id=turn:{turn_id}，billing_model=upfront-once）→
        余额不足 → 402 POINTS_INSUFFICIENT（零副作用）；轮末 settle(is_final=true)，
        失败 unfreeze；未定价快照免计费（points_charged 为 null）。
      parameters:
        - $ref: '#/components/parameters/OnBehalfOf'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TurnCreateRequest' }
      responses:
        '200':
          description: |
            text/event-stream（响应头 Cache-Control: no-cache、X-Accel-Buffering: no）。
            事件帧序列（每帧含 id / event / data 三行，id 支持 Last-Event-ID 续传）：
            - `event: turn_started`（首帧即带 turn_id，断连凭它重拉）
              `data: {"turn_id": "<uuid>", "seq": <int>, "status": "generating"}`
            - `event: turn_delta` ×N（echo 文本定长切块）
              `data: {"turn_id": "<uuid>", "seq": <int>, "text": "<string>"}`
            - `event: turn_card` ×0..1（CV2-4：模型以 choice_card 结束轮次且快照
              card_types_enabled 含 choice_card 时下发；禁用/非法卡片纯文本降级为 fallback_text）
              `data: {"turn_id": "<uuid>", "card": {"type": "choice_card",
                      "card_schema_version": 1, "prompt": "...",
                      "options": [{"id": "...", "label": "..."}], "multi": false,
                      "fallback_text": "..."}}`
              下一轮用户可回传 `{"type": "choice_selection", "card_message_id": "...",
              "selected": ["<option_id>"]}` 输入块
            - `event: turn_file` ×0..N（MM2-2 / ADR-0055 D5：generate_image / write_artifact
              工具产出文件时逐文件下发；新增事件名，旧客户端天然忽略）
              `data: {"turn_id": "<uuid>", "file": {"type": "file", "file_id": "<uuid>",
                      "filename": "image-1.png", "mime_type": "image/png", "size_bytes": 12345}}`
              产物同时落 assistant 消息 content_blocks 尾部（与输入 file 块同形，方向由 role 区分）；
              下载走 GET /conversations/{id}/files/{file_id}（校验 conversation_attachments 归属）
            - `event: turn_completed`（尾帧，含账单摘要）
              `data: {"turn_id": "<uuid>", "status": "succeeded", "points_charged": <int|null>,
                      "message_ids": ["<user_msg_uuid>", "<assistant_msg_uuid>"]}`
              可选键 `balance_warning: true`（ADR-0050 D4：结清后可用余额 < 3 x points_per_turn
              时附带，仅提示不阻断）
          content:
            text/event-stream:
              schema:
                type: string
                description: SSE 帧序列；事件名枚举 turn_started / turn_delta / turn_card / turn_file / turn_completed
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/conversations/{conversationId}/turns/{turnId}:
    parameters:
      - in: path
        name: conversationId
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: turnId
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: conversation.turn.get
      tags: [conversation]
      summary: 查询轮次结果（断连重拉主路径，含该轮全部消息，不重跑）
      x-rbac: [conversation:read]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: false
      security:
        - agentHubApiKey: []
        - bearerAuth: []
      description: |
        SSE 中途断开后凭 turn_id 重拉完整结果；重复拉取幂等（不产生新消息/新轮次）。
        会话不存在 → 404 CONVERSATION_NOT_FOUND；轮次不存在 → 404 CONVERSATION_TURN_NOT_FOUND。
      parameters:
        - $ref: '#/components/parameters/OnBehalfOf'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TurnOut' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/conversations/{conversationId}:end:
    parameters:
      - in: path
        name: conversationId
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: conversation.end
      tags: [conversation]
      summary: 显式结束会话（active → closing，幂等）
      x-rbac: [conversation:end]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: false
      security:
        - agentHubApiKey: []
        - bearerAuth: []
      description: |
        active → closing 即时返回（CV1-3 起），闭合管线（mock 编制钩子，CV4-2 换真链）
        异步完成 closing → closed；beat 空闲扫描走同一钩子。
        幂等：closing/closed 会话重复调用返回当前状态 200，不重复触发钩子。
      parameters:
        - $ref: '#/components/parameters/OnBehalfOf'
      responses:
        '200':
          description: 会话当前状态（首次调用为 closing；闭合完成后重调为 closed）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConversationOut' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/conversations/{conversationId}/messages:
    parameters:
      - in: path
        name: conversationId
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: conversation.message.list
      tags: [conversation]
      summary: 会话历史消息分页（created_at, id 升序）
      x-rbac: [conversation:read]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: false
      security:
        - agentHubApiKey: []
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OnBehalfOf'
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: offset
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MessagePageOut' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/conversations/{conversationId}/transcriptions:
    parameters:
      - in: path
        name: conversationId
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: conversation.transcription.create
      tags: [conversation]
      summary: 轮次前置语音转写（multipart 音频直传；文本回填输入框后作为普通 text 轮次发送；MM1-2 / ADR-0055 D2）
      x-rbac: [conversation:speech]
      x-idempotent: false
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: points_per_transcription
      security:
        - agentHubApiKey: []
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OnBehalfOf'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [audio]
              properties:
                audio:
                  type: string
                  format: binary
                  description: 音频文件（不转码；MIME 白名单 wav/mpeg/ogg/opus/webm/mp4/m4a 族，字节上限 CONVERSATION_SPEECH_MAX_UPLOAD_BYTES）
                language:
                  type: string
                  maxLength: 16
                  required: false
                  description: 语言提示（如 zh），透传 STT 供应商
      responses:
        '200':
          description: 转写结果（用户可修正后发轮次；不进轮次管线不烧轮次积分）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TranscriptionOut' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/conversations/{conversationId}/messages/{messageId}/speech:
    parameters:
      - in: path
        name: conversationId
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: messageId
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: conversation.message.speech
      tags: [conversation]
      summary: 助手消息按需合成语音（首次合成扣 points_per_speech；同音色缓存命中零扣点返回同 URL；MM1-2 / ADR-0055 D3）
      x-rbac: [conversation:speech]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: points_per_speech (cache miss only)
      security:
        - agentHubApiKey: []
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OnBehalfOf'
      responses:
        '200':
          description: 音频产物（storage_files 签名下载 URL；音色取 Agent 会话配置块 voice）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MessageSpeechOut' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/conversations/{conversationId}/files/{fileId}:
    parameters:
      - in: path
        name: conversationId
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: fileId
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: conversation.file.download
      tags: [conversation]
      summary: 会话文件签名下载 URL（附件上传与工具产物同路；校验 conversation_attachments 归属；MM2-2 / ADR-0055 D5）
      x-rbac: [conversation:read]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: false
      security:
        - agentHubApiKey: []
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OnBehalfOf'
      responses:
        '200':
          description: 短 TTL 签名下载 URL（closed 会话的历史文件仍可读）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConversationFileOut' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/conversations/{conversationId}/attachments:authorize:
    parameters:
      - in: path
        name: conversationId
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: conversation.attachment.authorize
      tags: [conversation]
      summary: 会话附件上传授权（票据 fid + 直传 URL；MIME 子集 pdf/txt/md/csv/图片，字节上限 CONVERSATION_ATTACHMENT_MAX_UPLOAD_BYTES；MM2-1 / ADR-0055 D5）
      x-rbac: [conversation:attachments]
      x-idempotent: false
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: none (并入轮次，不单独扣点)
      security:
        - agentHubApiKey: []
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OnBehalfOf'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AttachmentAuthorizeIn' }
      responses:
        '200':
          description: 上传票据（与 KB files:authorize 同形；fid 即能力凭证；会话须 active）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AttachmentAuthorizeOut' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/app/conversations/{conversationId}/attachments:commit:
    parameters:
      - in: path
        name: conversationId
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: conversation.attachment.commit
      tags: [conversation]
      summary: 提交附件并服务端抽取文本摘录（txt/md 直读、csv 列头+行摘要、pdf 文本层 pypdf、图片 MM4 前占位；MM2-1 / ADR-0055 D5）
      x-rbac: [conversation:attachments]
      x-idempotent: true
      x-tenant-scope: tenant+user
      x-async: false
      x-billing: none (并入轮次，不单独扣点)
      security:
        - agentHubApiKey: []
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/OnBehalfOf'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AttachmentCommitIn' }
      responses:
        '200':
          description: 摘录结果（excerpt 供 CV4-3 file 输入块组装；extraction_status ok/no_text_layer/vision_deferred；sha256 幂等重放 reused=true）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AttachmentCommitOut' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # 7.11 Public / Internal / Webhook 域
  # 详情见 endpoints/public-internal-webhook.md
  # ============================================================
  /api/v1/public/health:
    get:
      operationId: public.health.check
      tags: [public]
      summary: 进程级存活探针（K8s livenessProbe）
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: global
      x-async: false
      x-billing: false
      x-auth: anonymous
      description: |
        只反映进程是否在跑；禁止检查 DB / Valkey / Keycloak 等下游，避免下游抖动导致 Pod 误杀。
        网关白名单豁免限流。
      security: []
      responses:
        '200':
          description: 进程存活
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HealthCheckResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/public/readiness:
    get:
      operationId: public.readiness.check
      tags: [public]
      summary: 就绪探针（K8s readinessProbe）
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: global
      x-async: false
      x-billing: false
      x-auth: anonymous
      description: |
        检查关键依赖（PostgreSQL / Valkey / Keycloak / 对象存储）是否可达；
        单依赖检查超时 1s，总超时 2s；超时即判 not_ready。
        verbose=true 仅在内网或带 X-Internal-Probe-Token 时允许，避免泄漏拓扑。
      security: []
      parameters:
        - in: query
          name: verbose
          required: false
          schema: { type: boolean, default: false }
          description: true 时返回每项依赖明细；生产建议关闭
      responses:
        '200':
          description: 全部就绪
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReadinessCheckResponse' }
        '503':
          description: 任一关键依赖不可达 / 超时
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReadinessCheckResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/public/version:
    get:
      operationId: public.version.get
      tags: [public]
      summary: API 版本 / 构建号 / git commit / 最低客户端版本
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: global
      x-async: false
      x-billing: false
      x-auth: anonymous
      description: |
        用于运维与前端兼容性核对；min_client_version 用于强制升级提示，
        前端低于该版本时应阻断业务并引导升级。
      security: []
      responses:
        '200':
          description: 版本信息
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VersionInfoResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/public/manifests/{app_id}/runtime:
    parameters:
      - in: path
        name: app_id
        required: true
        schema: { type: string }
        description: Manifest 业务标识（与 L2-D 中定义一致）
    get:
      operationId: public.manifest.get_runtime
      tags: [public]
      summary: 匿名获取 Manifest 运行期最小切片（不含敏感配置）
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: global
      x-async: false
      x-billing: false
      x-auth: anonymous
      description: |
        与登录态 saas.manifest.get 形成对位；剥离 payment_config / login_config / internal_metadata 等敏感字段。
        默认限流 60/min/IP，命中网关边缘缓存优先。
      security: []
      parameters:
        - in: query
          name: channel
          required: false
          schema: { type: string, enum: [web, miniapp, h5], default: web }
          description: 用于裁剪 frontend_features
        - in: query
          name: manifest_version
          required: false
          schema: { type: string }
          description: 指定历史已发布版本；不允许 draft / deprecated
      responses:
        '200':
          description: 公开 Manifest 切片
          headers:
            X-Cache:
              schema: { type: string, enum: [HIT, MISS] }
              description: 边缘缓存命中标记
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PublicManifestRuntime' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/internal/tasks/{task_id}/callback:
    parameters:
      - in: path
        name: task_id
        required: true
        schema: { type: string, format: uuid }
        description: 任务主键，需在当前租户内
    post:
      operationId: internal.task.callback
      tags: [internal]
      summary: Worker / SOP 引擎节点完成回写主服务（驱动 RuntimeEvent 推送）
      x-rbac: [internal:task_writeback]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      x-auth: mTLS
      description: |
        计费动作不在本接口触发，由 internal.budget.update 单独负责。
        event_seq 单调递增由调用方维护；崩溃恢复后从上次未确认的 seq 重发。
      security:
        - mTLS: []
          apiKeyHmac: []
      parameters:
        - in: header
          name: X-Tenant-Id
          required: true
          schema: { type: string, format: uuid }
          description: 调用方必须显式注入；缺失返回 400 INTERNAL_TENANT_MISSING
        - in: header
          name: X-Internal-Caller
          required: true
          schema: { type: string }
          description: 调用方 ServiceAccount 名称（审计可读标识，不参与鉴权决策）
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InternalTaskCallbackRequest' }
      responses:
        '200':
          description: 已接受（幂等返回首结果）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InternalTaskCallbackResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/internal/budgets/update:
    post:
      operationId: internal.budget.update
      tags: [internal]
      summary: 成本归集 Sidecar 回写真实用量（驱动预算熔断与对账）
      x-rbac: [internal:budget_update]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      x-auth: mTLS
      description: |
        本接口是"事后用量回写"，不直接扣减用户积分；积分扣减由 SOP 引擎在节点结束时按回执触发。
        历史回填仅允许 24h 内；超过则进 late_arrival 桶，由对账接口处理。
      security:
        - mTLS: []
          apiKeyHmac: []
      parameters:
        - in: header
          name: X-Tenant-Id
          required: true
          schema: { type: string, format: uuid }
        - in: header
          name: X-Internal-Caller
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InternalBudgetUpdateRequest' }
      responses:
        '200':
          description: 已累加（含熔断器最新状态）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InternalBudgetUpdateResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/webhook/wxpay/notify:
    post:
      operationId: webhook.wxpay.notify
      tags: [webhook]
      summary: 微信支付（V3）异步支付结果通知
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      x-auth: hmac
      description: |
        签名校验 + 解密 + 状态机推进；服务端必须 5s 内返回 200 SUCCESS，否则视为失败。
        重放窗口 5 分钟；apiv3_key 存放于 ExternalCredential，按 (tenant_id, vendor='wxpay') 查找。
      security:
        - apiKeyHmac: []
      parameters:
        - in: header
          name: Wechatpay-Signature
          required: true
          schema: { type: string }
        - in: header
          name: Wechatpay-Timestamp
          required: true
          schema: { type: string }
        - in: header
          name: Wechatpay-Nonce
          required: true
          schema: { type: string }
        - in: header
          name: Wechatpay-Serial
          required: true
          schema: { type: string }
      requestBody:
        required: true
        description: 微信支付 V3 通知报文（外层加密 + 内层 resource.ciphertext）
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              description: 微信侧报文按"自由对象 + 描述"建模，避免与第三方版本耦合
      responses:
        '200':
          description: SUCCESS
          content:
            application/json:
              schema:
                type: object
                required: [code, message]
                properties:
                  code: { type: string, enum: [SUCCESS] }
                  message: { type: string, example: '成功' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/webhook/ocr/callback:
    post:
      operationId: webhook.ocr.callback
      tags: [webhook]
      summary: OCR 供应商异步识别结果回调（默认阿里云）
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      x-auth: hmac
      description: |
        task_id UNIQUE 落库 ocr_callbacks；多供应商场景按 URL 子路径区分，
        例如 /api/v1/webhook/ocr/ali/callback、/api/v1/webhook/ocr/baidu/callback。
      security:
        - apiKeyHmac: []
      parameters:
        - in: header
          name: X-Vendor-Signature
          required: true
          schema: { type: string }
          description: HMAC-SHA256 签名；密钥按 (tenant_id, vendor='ali_ocr') 查 ExternalCredential
        - in: header
          name: X-Vendor-Timestamp
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OcrCallbackRequest' }
      responses:
        '200':
          description: 已接受
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OcrCallbackResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/webhook/feishu/notify:
    post:
      operationId: webhook.feishu.notify
      tags: [webhook]
      summary: 飞书事件订阅 v2（卡片回调 / 审批回执 / 群聊事件）
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      x-auth: hmac
      description: |
        事件首次绑定时飞书会发送 url_verification challenge，必须按官方规范原样回显。
        飞书要求 3s 内 ACK；耗时操作必须异步化，回调线程只做"鉴权 + 入队 + 200"。
        event_id 是天然幂等键。
      security:
        - apiKeyHmac: []
      parameters:
        - in: header
          name: X-Lark-Signature
          required: true
          schema: { type: string }
        - in: header
          name: X-Lark-Request-Timestamp
          required: true
          schema: { type: string }
        - in: header
          name: X-Lark-Request-Nonce
          required: true
          schema: { type: string }
      requestBody:
        required: true
        description: 飞书事件订阅 v2 报文（schema/header/event 三段结构）
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              description: 飞书侧报文按"自由对象 + 描述"建模，避免与第三方版本耦合
      responses:
        '200':
          description: |
            普通事件返回 { "code": 0 }；url_verification 返回 { "challenge": "<原值>" }。
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        default:
          $ref: '#/components/responses/Error'

  # =====================================================================
  # T2 (ADR-0044): manual points grants + ledger + membership maintenance
  # =====================================================================

  /api/v1/billing/points:grant:
    post:
      operationId: billing.points.grant
      tags: [billing.points]
      summary: ops 手动入账（平台→租户影子用户，可跨租户）
      x-rbac: [billing:points:grant]
      x-idempotent: true
      x-tenant-scope: platform
      x-async: false
      x-billing: true
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AdminGrantRequest' }
      responses:
        '201':
          description: Created（重复 operation_id 幂等回放同样返回 201）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GrantResult' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/billing/points:tenant-grant:
    post:
      operationId: billing.points.tenant_grant
      tags: [billing.points]
      summary: 租户手动入账（租户→本租户影子用户；跨租户 403 fail-closed）
      x-rbac: [billing:points:tenant_grant]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: true
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TenantGrantRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GrantResult' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/billing/points/ledger:
    get:
      operationId: billing.points.ledger.list
      tags: [billing.points]
      summary: 积分流水分页查询（ops 持 grant scope 可带 tenant_id 跨租户）
      x-rbac: [billing:ledger:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: user_id
          schema: { type: string, format: uuid }
        - in: query
          name: project_id
          schema: { type: string, format: uuid }
        - in: query
          name: transaction_type
          schema: { type: string }
        - in: query
          name: tenant_id
          schema: { type: string, format: uuid }
          description: 跨租户查询（须持 billing:points:grant scope）
        - in: query
          name: start
          schema: { type: string, format: date-time }
        - in: query
          name: end
          schema: { type: string, format: date-time }
        - in: query
          name: limit
          schema: { type: integer, default: 50, maximum: 200 }
        - in: query
          name: offset
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LedgerPage' }
        default:
          $ref: '#/components/responses/Error'

  # ------------------------------------------------------------------
  # PP-0 (ADR-0054): C 端个人面板发布面 — 以实现为准回写
  # （旧 /app/points* / /app/orders* 草案已物理删除，历史见 endpoints/saas-points.md）
  # ------------------------------------------------------------------
  /api/v1/billing/points/balance:
    get:
      operationId: billing.points.balance.read
      tags: [billing.points]
      summary: 当前用户在指定项目下的积分余额（member + long-term 聚合）
      x-rbac: [billing:points:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: |
        平台 JWT（console 通道）。tenant_id / user_id 只来自令牌；project_id 为显式请求参数。
        user_id 须等于 JWT user_id，否则 403 AUTH_TENANT_MISMATCH（应用层前置校验，防跨用户枚举）。
        RLS 经 get_tenant_scoped_session 注入 app.current_tenant GUC。无账户用户返回 0 余额（graceful empty）。
        响应为统一 Envelope（code/message/data）包裹 BalanceView。
      parameters:
        - in: query
          name: user_id
          required: true
          schema: { type: string, format: uuid }
          description: 须等于 JWT user_id（只读自己）
        - in: query
          name: project_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillingBalanceEnvelope' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/billing/recharge-plans:
    get:
      operationId: billing.recharge_plans.list
      tags: [billing.payment]
      summary: 服务端权威充值档位目录（read-only，无需鉴权）
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: none
      x-async: false
      x-billing: false
      description: |
        返回服务端权威充值档位（SM3-1 Cycle 3；PE2-2 / ADR-0060 D5 升维 per-channel
        per-currency）：
        wechat/CNY 四档（1000→100 / 5000→500 / 10000→1000 / 20000→2000，单位分→积分）
        + paypal/USD 占位四档（200→100 / 1000→500 / 2000→1000 / 4000→2000，单位美分→积分，
        运营可调；point_amount 锚定积分不做汇率，ADR-0060 D6）。
        每条 item 携带 channel + currency；amount_cents = 该 currency 的最小货币单位。
        前端按自身支付渠道过滤（wechat 端只消费 channel=wechat 且 currency=CNY 档）。
        无需鉴权（公开价目信息）。前端必须在结账时拉取此列表，禁止硬编码价格。
        前端缓存：此接口幂等且稳定，SPA 可在会话内缓存，但发版后须刷新。
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RechargePlanListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/payment/orders:
    post:
      operationId: payment.order.create
      tags: [billing.payment]
      summary: 创建充值支付订单（NATIVE 主路径，返回 code_url 二维码）
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: |
        平台 JWT（登录态即可）。tenant_id / user_id 由 JWT 权威覆盖，body 同名字段仅作
        schema 透传（SEM-018 R4）。mchid/appid 由后端经 Seam ② 解析，绝不采信 body。
        recharge 订单 amount_cents 须命中服务端充值目录（PE2-2 起按 channel×currency
        查表：trade_type 推导渠道、渠道推导唯一币种；wechat/CNY 四档 1000→100 /
        5000→500 / 10000→1000 / 20000→2000，单位分→积分），points_amount 由服务端覆写，
        否则 422。PAYPAL_WEB schema 放行但端点 503 fail-closed（PE2-3 前）。
        幂等：out_trade_no / local_order_no。SIGNTEST 探针流量 400 拒绝。
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentOrderCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentOrderCreateResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/payment/orders/{order_id}/status:
    get:
      operationId: payment.order.status.read
      tags: [billing.payment]
      summary: 轮询支付订单状态（Native QR 流，read-only）
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: |
        平台 JWT。tenant_id 取 JWT；订单不存在或不属于本租户统一 404（防枚举）。
        订单仍处 created/pending 时触发微信侧主动查单回填（active-query fallback）。
        不暴露凭据与内部金额明细。
      parameters:
        - in: path
          name: order_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderStatusResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/payment/orders/{order_id}/refund:
    post:
      operationId: payment.order.refund.create
      tags: [billing.payment]
      summary: 申请微信支付退款
      x-rbac: [billing:refund:write]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: |
        平台 JWT + billing:refund:write scope。幂等键 out_refund_no 由客户端持有并保证
        重试稳定。订单须属于调用者，除非持 ops:payment_manage 运营 scope（客服代退）。
        预检 Σ(既有退款)+本次 ≤ order.amount_cents；积分回冲由退款回调闭环。
      parameters:
        - in: path
          name: order_id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RefundCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RefundResponse' }
        default:
          $ref: '#/components/responses/Error'

  # PE2-3 Cycle 4: PayPal inbound payment/refund webhook (Route A, ADR-0060 D8)
  /api/v1/billing/webhook/paypal:
    post:
      operationId: billing.webhook.paypal
      tags: [billing.payment]
      summary: PayPal 入站支付/退款回调（Route A 验签）
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: none
      x-async: false
      x-billing: false
      x-auth: paypal-signature
      description: |
        PayPal webhook 回调端点（PAYMENT.CAPTURE.COMPLETED / PAYMENT.CAPTURE.REFUNDED）。
        无需平台 JWT（精确路径加入 JWT 白名单）；验签由 PayPal /v1/notifications/verify-webhook-signature
        API 完成（Route A，ADR-0060 D8）；cert_url 域名白名单防 SSRF（我方不下载证书）。
        重放防护：paypal-transmission-id 单次消费（ValkeyReplayStore，TTL = 2× 时间窗）。
        幂等：operation_id = pp_cb:<capture_id> 与 uk_pledger_operation 对齐，重放余额不变。
        凭据缺席或服务未装配 → 503 fail-closed（与 /billing/webhook/wxpay 同款）。
      security: []
      parameters:
        - in: header
          name: paypal-transmission-id
          required: true
          schema: { type: string }
          description: PayPal 唯一请求 ID（用于重放防护）
        - in: header
          name: paypal-transmission-time
          required: true
          schema: { type: string, format: date-time }
          description: RFC3339 时间戳（±300s 窗口校验）
        - in: header
          name: paypal-auth-algo
          required: true
          schema: { type: string }
          description: 签名算法（通常 SHA256withRSA）
        - in: header
          name: paypal-cert-url
          required: true
          schema: { type: string, format: uri }
          description: PayPal 证书 URL（仅域名白名单校验，不下载）
        - in: header
          name: paypal-transmission-sig
          required: true
          schema: { type: string }
          description: Base64 编码的 PayPal 签名
      requestBody:
        required: true
        description: PayPal webhook 事件报文（JSON 原文）
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              description: PayPal 事件对象（event_type / resource / create_time 等字段）
      responses:
        '200':
          description: 处理成功（已验签 + 已幂等处理）
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string, enum: [ok] }
        '400':
          description: 验签失败 / SSRF 检测 / 重放拒绝
          $ref: '#/components/responses/Error'
        '503':
          description: PayPal 服务未配置（凭据缺席 → fail-closed）
          $ref: '#/components/responses/Error'
        default:
          $ref: '#/components/responses/Error'

  /api/v1/billing/points-cards/redeem:
    post:
      operationId: billing.points_cards.redeem
      tags: [billing.points]
      summary: C 端自助兑换积分卡（入账长效积分）
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: |
        平台 JWT（登录态即可，无额外 scope——与 payment.order.create 同档）+ Idempotency-Key。
        tenant_id / user_id 只取 JWT；project_id 为显式请求字段（须属 JWT 租户，与
        balance / ledger 惯例同构）。核心安全不变量（ADR-0054 D1）：卡的 tenant_id 必须
        等于 JWT tenant_id（租户级发卡）。
        卡码 / 卡密双 HMAC-SHA256（card_code_hash 查表 + 恒定时间密码比对，L2-C §11）；
        卡级 CAS（status='unused' AND valid_until > now()）；入账经 CreditService
        source='points_card'（ledger card_redeem + 长效批次，expires_at = 兑换时刻 +
        默认 2 年）；同事务回填 points_cards.redeemed_user_id / redeemed_project_id /
        redeemed_batch_id。
        防枚举：卡不存在 / 密码错 / 已兑（非同键重放）/ 作废 / 过期 / 未生效 / 跨租户 /
        project 不属租户，统一 404 CARD_NOT_FOUND（响应体一致）；真因仅入 WORM 审计。
        幂等三层：Idempotency-Key（uk_pcard_redeem_key，同键异卡 409）+
        points_ledger operation_id（card_redeem:{card_id}:{user_id}）+ 卡状态机 CAS。
        同键同人重放返回首结果（idempotent_replay=true），不重复入账。
        旧 Session-Token 草案的 60s 冷却 / IP 熔断不实现（ADR-0054 历史化裁定）。
      parameters:
        - in: header
          name: Idempotency-Key
          required: true
          schema: { type: string, minLength: 8, maxLength: 128 }
          description: |
            客户端持有的幂等键（本端点覆写共享组件语义：必填且 8..128，不强制 uuid）。
            同键同人重放返回首结果（idempotent_replay=true）；同键异卡 409。
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PointsCardRedeemRequest' }
      responses:
        '201':
          description: Created（同 Idempotency-Key 重放同样返回 201 + idempotent_replay=true）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PointsCardRedeemEnvelope' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # PP-2: SaaS — scene profile（scene 扩展表，与 student_profiles 分表；
  # 契约与实现同包落地，ADR-0039 / 方案 §5.3-2）
  # ============================================================
  /api/v1/app/scene-profile:
    get:
      operationId: saas.scene_profile.get
      tags: [saas.scene-profile]
      summary: 当前用户在指定项目 + 场景下的扩展画像
      x-rbac: [profile:manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: |
        平台 JWT + scope profile:manage（复用 student 画像同款 scope，无新 scope）。
        tenant_id / user_id 只来自令牌；project_id / scene_code 为显式 query 参数。
        scene_code 须在服务端 allowlist 内（初始仅 bid_review），未登记 → 422。
        行不存在 → 404 PROFILE_NOT_FOUND 统一文案（不 lazy-create，读路径零意外写）。
        RLS 经 get_tenant_scoped_session 注入 app.current_tenant GUC。
      parameters:
        - in: query
          name: project_id
          required: true
          schema: { type: string, format: uuid }
          description: 项目作用域（(tenant, project, user, scene) 分表键）
        - in: query
          name: scene_code
          required: true
          schema: { type: string, pattern: '^[a-z0-9_]{1,32}$' }
          description: 场景代码（allowlist：bid_review）
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SceneProfileEnvelope' }
        default:
          $ref: '#/components/responses/Error'
    patch:
      operationId: saas.scene_profile.patch
      tags: [saas.scene-profile]
      summary: upsert 修正当前用户的场景扩展画像
      x-rbac: [profile:manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: |
        upsert 语义：行不存在则建档（首次 PATCH 即建档）；已存在则先浅合并 patch
        （顶层键覆盖）再应用 remove_keys（remove 赢，与 student 画像 correct 语义
        一致）。project 不属 JWT 租户 → 404 统一文案（不泄因）。
        审计 emit scene.profile_corrected（脱敏 payload，不含 profile_data 明文）。
      parameters:
        - in: query
          name: project_id
          required: true
          schema: { type: string, format: uuid }
          description: 项目作用域；须属 JWT 租户
        - in: query
          name: scene_code
          required: true
          schema: { type: string, pattern: '^[a-z0-9_]{1,32}$' }
          description: 场景代码（allowlist：bid_review）
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SceneProfilePatchRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SceneProfileEnvelope' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/billing/memberships:grant:
    post:
      operationId: billing.memberships.grant
      tags: [billing.membership]
      summary: 会员授予/续期（不自动发积分）
      x-rbac: [billing:membership:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MembershipGrantRequest' }
      responses:
        '201':
          description: Created（既有 active 会员时为续期顺延）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Membership' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/billing/memberships:revoke:
    post:
      operationId: billing.memberships.revoke
      tags: [billing.membership]
      summary: 停用（取消）当前 active 会员
      x-rbac: [billing:membership:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MembershipRevokeRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Membership' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/billing/memberships:
    get:
      operationId: billing.memberships.list
      tags: [billing.membership]
      summary: 会员列表（按 user/project 筛选）
      x-rbac: [billing:membership:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: user_id
          schema: { type: string, format: uuid }
        - in: query
          name: project_id
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Membership' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/billing/membership-plans:
    get:
      operationId: billing.membership_plans.list
      tags: [billing.membership]
      summary: 会员档位列表（会员维护页下拉数据源）
      x-rbac: [billing:membership:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/MembershipPlan' }
        default:
          $ref: '#/components/responses/Error'
    post:
      operationId: billing.membership_plans.create
      tags: [billing.membership]
      summary: 创建会员档位
      x-rbac: [billing:membership:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MembershipPlanCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MembershipPlan' }
        default:
          $ref: '#/components/responses/Error'

  # =====================================================================
  # T2 (ADR-0044 D2): API-key lifecycle (delivery)
  # =====================================================================

  /api/v1/delivery/api-keys:
    get:
      operationId: delivery.api_keys.list
      tags: [delivery.api_key]
      summary: API-key 列表（不含任何明文/摘要）
      x-rbac: [delivery:api_key:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ApiKey' }
        default:
          $ref: '#/components/responses/Error'
    post:
      operationId: delivery.api_keys.create
      tags: [delivery.api_key]
      summary: 创建 API-key（明文仅本响应返回一次）
      x-rbac: [delivery:api_key:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ApiKeyCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyCreated' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/delivery/api-keys/{key_id}:rotate:
    parameters:
      - in: path
        name: key_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: delivery.api_keys.rotate
      tags: [delivery.api_key]
      summary: 轮转 API-key（原子吊销旧 + 签发新；新明文仅返回一次）
      x-rbac: [delivery:api_key:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyCreated' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/delivery/api-keys/{key_id}:revoke:
    parameters:
      - in: path
        name: key_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: delivery.api_keys.revoke
      tags: [delivery.api_key]
      summary: 吊销 API-key（即时生效）
      x-rbac: [delivery:api_key:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKey' }
        default:
          $ref: '#/components/responses/Error'

  # =====================================================================
  # T3 (ADR-0045): Skill catalog CRUD
  # =====================================================================

  /api/v1/builder/skills:
    get:
      operationId: builder.skill.list
      tags: [builder.skill]
      summary: Skill 列表（按 project/status 筛选）
      x-rbac: [builder:skill:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - in: query
          name: project_id
          schema: { type: string, format: uuid }
        - in: query
          name: status
          schema: { type: string, enum: [enabled, disabled] }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Skill' }
        default:
          $ref: '#/components/responses/Error'
    post:
      operationId: builder.skill.create
      tags: [builder.skill]
      summary: 创建 Skill（slug 租户内唯一）
      x-rbac: [builder:skill:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SkillCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Skill' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/skills/{skill_id}:
    parameters:
      - in: path
        name: skill_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: builder.skill.get
      tags: [builder.skill]
      summary: Skill 详情
      x-rbac: [builder:skill:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Skill' }
        default:
          $ref: '#/components/responses/Error'
    patch:
      operationId: builder.skill.update
      tags: [builder.skill]
      summary: 局部更新（含启停；禁用即软删，无 DELETE）
      x-rbac: [builder:skill:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SkillUpdateRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Skill' }
        default:
          $ref: '#/components/responses/Error'

  # =====================================================================
  # CV2-3/CV5-1: tool/MCP catalog CRUD (ADR-0051 D1/D3)
  # =====================================================================

  /api/v1/builder/tools:
    get:
      operationId: builder.tool.list
      tags: [builder.tool]
      summary: 工具/MCP 目录列表
      x-rbac: [builder:tool:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Tool' }
        default:
          $ref: '#/components/responses/Error'
    post:
      operationId: builder.tool.create
      tags: [builder.tool]
      summary: 注册工具（slug 租户内唯一；MCP 保存期治理闸 fail-closed）
      x-rbac: [builder:tool:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: |
        保存期治理（ADR-0051 D3）：http/mcp_server URL 强制 https 且非 IP 直连；
        mcp_server 主机必须命中部署级 allowlist（空 allowlist 全拒），凭据只收
        OpenBao 引用（auth_ref），plaintext headers 拒绝；被拒保存入 WORM 审计。
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ToolCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tool' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/tools/{tool_id}:
    parameters:
      - in: path
        name: tool_id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: builder.tool.get
      tags: [builder.tool]
      summary: 工具详情
      x-rbac: [builder:tool:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tool' }
        default:
          $ref: '#/components/responses/Error'
    patch:
      operationId: builder.tool.update
      tags: [builder.tool]
      summary: 局部更新（含启停；禁用即软删，无 DELETE；config 变更重跑治理闸）
      x-rbac: [builder:tool:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ToolUpdateRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tool' }
        default:
          $ref: '#/components/responses/Error'

  # =====================================================================
  # T4: tenant overview + agent dry-run + model connectivity test
  # =====================================================================

  /api/v1/ops/tenants/overview:
    get:
      operationId: ops.tenants.overview
      tags: [ops.tenants]
      summary: 跨租户只读总览（ADR-0044 D4 GUC 迭代聚合）
      x-rbac: [ops:tenants:read]
      x-idempotent: true
      x-tenant-scope: platform
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantOverview' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/agents/{agent_id}/dry-run:
    parameters:
      - in: path
        name: agent_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: builder.agent.dry_run
      tags: [builder.agent]
      summary: Agent 试运行（只读成本估算，零计费写入）
      x-rbac: [agent:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AgentDryRunRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentDryRunResult' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/agents/{agent_id}/chat-dry-run:
    parameters:
      - in: path
        name: agent_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: builder.agent.chat_dry_run
      tags: [builder.agent]
      summary: 会话 Agent chat 预览（CV5-2；零持久化/零计费/空工具）
      x-rbac: [agent:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      description: |
        针对已发布 agent_type=conversation 的快照运行一轮预览：不写会话/轮次/消息，
        不进冻结计费路径，工具集为空（预览绝不触发真实 SOP 提交/空间写/MCP 调用）。
        返回 assistant 文本 + 解析后的运行时配置摘要（单价/护栏/绑定 KB/工具计数/SOP 白名单）。
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChatDryRunRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChatDryRunResult' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/builder/models/{model_id}:test:
    parameters:
      - in: path
        name: model_id
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: builder.model.test
      tags: [builder.model]
      summary: 模型连通性测试（只读、SSRF 守卫、超时保护）
      x-rbac: [builder:model:read]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ModelConnectivityTestRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelConnectivityTestResult' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # Identity — /me（IE-1 / ADR-0047）
  # ============================================================
  /api/v1/me:
    get:
      operationId: identity.me.get
      tags: [identity.me]
      summary: 当前用户资料（含 JIT 首登 upsert）
      description: |
        返回本地 `users` 行与租户摘要。若尚无本地行，按 JWT `sub`（keycloak_user_id）
        幂等创建个人租户 + users + 默认 `saas_user` 角色，并回写 Keycloak 属性。
        登录即有读权限（不额外要求 scope）。首登后若回写了 claim，响应
        `token_refresh_required=true`，客户端应刷新 token。
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MeResponse' }
        default:
          $ref: '#/components/responses/Error'
    patch:
      operationId: identity.me.patch
      tags: [identity.me]
      summary: 更新当前用户可自改字段（name / avatar_url）
      x-rbac: [identity:me:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MePatchRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MeResponse' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # Identity — tenants / invites / account delete（IE-2 / IE-4）
  # ============================================================
  /api/v1/tenants:
    post:
      operationId: identity.tenants.upgrade
      tags: [identity.tenants]
      summary: 个人租户升级为正式租户
      description: |
        将当前用户的 personal 租户升级为 formal（改 name/slug、授予 tenant_admin）。
        成功后通常 `token_refresh_required=true`，客户端应重新登录或刷新 token。
      x-rbac: []
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TenantUpgradeRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantSummary' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/tenants/current:
    get:
      operationId: identity.tenants.current.get
      tags: [identity.tenants]
      summary: 获取当前租户摘要
      x-rbac: [tenant:admin]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantSummary' }
        default:
          $ref: '#/components/responses/Error'
    patch:
      operationId: identity.tenants.current.patch
      tags: [identity.tenants]
      summary: 更新当前正式租户名称
      x-rbac: [tenant:admin]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name: { type: string, minLength: 1, maxLength: 128 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantSummary' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/tenants/current/members:
    get:
      operationId: identity.tenants.members.list
      tags: [identity.tenants]
      summary: 列出当前租户成员
      x-rbac: [tenant:user_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/MemberSummary' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/tenants/current/members/{member_id}:
    patch:
      operationId: identity.tenants.members.patch
      tags: [identity.tenants]
      summary: 更新成员角色
      x-rbac: [tenant:user_manage]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - name: member_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role_code]
              additionalProperties: false
              properties:
                role_code: { type: string, minLength: 1, maxLength: 64 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MemberSummary' }
        default:
          $ref: '#/components/responses/Error'
    delete:
      operationId: identity.tenants.members.delete
      tags: [identity.tenants]
      summary: 禁用 / 移除成员
      x-rbac: [tenant:user_manage]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - name: member_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [id, status]
                properties:
                  id: { type: string, format: uuid }
                  status: { type: string, enum: [disabled] }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/tenants/current/invites:
    post:
      operationId: identity.tenants.invites.create
      tags: [identity.invites]
      summary: 创建租户邀请码
      x-rbac: [tenant:user_manage]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InviteCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InviteResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/tenants/current/m2m-clients:
    get:
      operationId: identity.tenants.m2m_clients.list
      tags: [identity.tenants]
      summary: 列出租户 M2M 凭据客户端（永不返回 secret）
      x-rbac: [identity:m2m:write]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/M2mClientSummary' }
        default:
          $ref: '#/components/responses/Error'
    post:
      operationId: identity.tenants.m2m_clients.create
      tags: [identity.tenants]
      summary: 铸造租户 M2M 凭据客户端；secret 仅本次响应返回一次
      x-rbac: [identity:m2m:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/M2mClientCreateRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/M2mClientSecret' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/tenants/current/m2m-clients/{client_uuid}/rotate:
    post:
      operationId: identity.tenants.m2m_clients.rotate
      tags: [identity.tenants]
      summary: 轮转 M2M 客户端 secret；旧 secret 立即失效
      x-rbac: [identity:m2m:write]
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - name: client_uuid
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/M2mClientSecret' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/tenants/current/m2m-clients/{client_uuid}:
    delete:
      operationId: identity.tenants.m2m_clients.revoke
      tags: [identity.tenants]
      summary: 吊销 M2M 客户端
      x-rbac: [identity:m2m:write]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      parameters:
        - name: client_uuid
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [revoked, client_uuid]
                properties:
                  revoked: { type: boolean }
                  client_uuid: { type: string }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/tenants/invites/redeem:
    post:
      operationId: identity.tenants.invites.redeem
      tags: [identity.invites]
      summary: 兑换邀请码（加入新域 + 切换活跃域）
      description: |
        SM1-2 重定义（§5.1 G-3）：任何活跃域的用户均可兑换邀请码加入新域；
        不再限制仅 personal 租户用户。原归属保留，不被覆盖。幂等重放（已是目标域成员）
        不消耗邀请名额。member.id 为目标域新建的 users 行 id。
      x-rbac: []
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              additionalProperties: false
              properties:
                code: { type: string, minLength: 4, maxLength: 128 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [tenant, member, token_refresh_required]
                properties:
                  tenant: { $ref: '#/components/schemas/TenantSummary' }
                  member: { $ref: '#/components/schemas/MemberSummary' }
                  token_refresh_required: { type: boolean }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/me/active-tenant:
    post:
      operationId: identity.me.active_tenant.switch
      tags: [identity.me]
      summary: 切换活跃域（SM1-2）
      description: |
        切换调用者的活跃域至 tenant_id。调用者必须已归属该域（非 deleted）。
        不存在的 tenant 与未归属的 tenant 返回同一 403（反枚举）。
        成功后 Keycloak 属性四键原子写（tenant_id / user_id / agenthub_user_id /
        permissions）；客户端须刷新 token 获得新 claim。
        旧 token 在其 TTL 内仍按旧域服务（窗口期：最后写入者赢）。
      x-rbac: [identity:me:write]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id]
              additionalProperties: false
              properties:
                tenant_id: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [active_tenant_id, tenant, token_refresh_required]
                properties:
                  active_tenant_id: { type: string, format: uuid }
                  tenant: { $ref: '#/components/schemas/TenantSummary' }
                  token_refresh_required: { type: boolean, enum: [true] }
        '403':
          description: 未归属目标域或目标域非 active（反枚举：两种情况返回同一响应）
          content:
            application/json:
              schema: { $ref: '#/components/responses/Error' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/me/memberships:
    get:
      operationId: identity.me.memberships.list
      tags: [identity.me]
      summary: 列出所有域归属（SM1-2）
      description: |
        跨域列举经 SECURITY DEFINER 受控通道，不放宽 RLS。
        仅返回 tenant 摘要 + 当前域标识，不泄露他域 scope / 权限信息（§R6）。
        is_active 标记哪个归属与当前 JWT tenant_id claim 一致（即当前活跃域）。
      x-rbac: []
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/MembershipItem' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/me/wechat-binding:
    post:
      operationId: identity.me.wechat_binding.create
      tags: [identity.me]
      summary: 绑定微信 openid（SM3-1 profile enrichment）
      description: |
        JWT 必需。语义 = 已认证用户把自己的微信 openid 绑定到 users 行，供 JSAPI 下单使用。
        不新增未认证入口、不签发 token/会话、不产生并行 IdP（ADR-0003 / ADR-0047 兼容）。
        AppSecret 经 OpenBao 读取；未配置 → 503 fail-closed。
        code 一次性消费（本地 Valkey + 微信侧双重防重放）。
        openid 按 (tenant_id, user_id) 持久化；同 tenant 内绑定到第二个用户 → 409。
      x-rbac: []
      x-idempotent: false
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WechatBindingRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WechatBindingResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/me/account:
    delete:
      operationId: identity.me.account.delete
      tags: [identity.me]
      summary: 退出当前活跃域（SM1-2），仅最后归属时禁用 KC 账号
      description: |
        SM1-2 §5.1 G-4 重定义：注销 = 退出当前活跃域；仅当这是最后一个非
        deleted 归属时才禁用 / 登出 Keycloak 账号。响应新增两个字段：
        remaining_memberships（退出后剩余归属数）和 keycloak_disabled。
      x-rbac: [identity:account:delete]
      x-idempotent: true
      x-tenant-scope: tenant
      x-async: false
      x-billing: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccountDeleteResponse' }
        default:
          $ref: '#/components/responses/Error'

  # ============================================================
  # Ops — tenants admin / users（IE-2）
  # ============================================================
  /api/v1/ops/tenants:
    get:
      operationId: ops.tenants.list
      tags: [ops.tenants.admin]
      summary: 列出租户（ops）
      x-rbac: [ops:tenants:read]
      x-idempotent: true
      x-tenant-scope: platform
      x-async: false
      x-billing: false
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OpsTenantListResponse' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/ops/tenants/{tenant_id}:
    get:
      operationId: ops.tenants.get
      tags: [ops.tenants.admin]
      summary: 租户详情（ops）
      x-rbac: [ops:tenants:read]
      x-idempotent: true
      x-tenant-scope: platform
      x-async: false
      x-billing: false
      parameters:
        - name: tenant_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantSummary' }
        default:
          $ref: '#/components/responses/Error'
    patch:
      operationId: ops.tenants.patch
      tags: [ops.tenants.admin]
      summary: 更新租户（ops）
      x-rbac: [ops:tenants:write]
      x-idempotent: false
      x-tenant-scope: platform
      x-async: false
      x-billing: false
      parameters:
        - name: tenant_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name: { type: string, minLength: 1, maxLength: 128 }
                status: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantSummary' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/ops/tenants/{tenant_id}/suspend:
    post:
      operationId: ops.tenants.suspend
      tags: [ops.tenants.admin]
      summary: 暂停租户（需已批准审批单）
      x-rbac: [ops:tenants:write]
      x-idempotent: false
      x-tenant-scope: platform
      x-async: false
      x-billing: false
      parameters:
        - name: tenant_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ApprovalActionRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantSummary' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/ops/tenants/{tenant_id}/resume:
    post:
      operationId: ops.tenants.resume
      tags: [ops.tenants.admin]
      summary: 恢复已暂停租户
      x-rbac: [ops:tenants:write]
      x-idempotent: false
      x-tenant-scope: platform
      x-async: false
      x-billing: false
      parameters:
        - name: tenant_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantSummary' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/ops/users:
    get:
      operationId: ops.users.list
      tags: [ops.users]
      summary: 跨租户用户列表（ops）
      x-rbac: [ops:users:read]
      x-idempotent: true
      x-tenant-scope: platform
      x-async: false
      x-billing: false
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
        - name: tenant_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items, has_more, offset, limit]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/MemberSummary' }
                  has_more: { type: boolean }
                  offset: { type: integer }
                  limit: { type: integer }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/ops/users/{user_id}/disable:
    post:
      operationId: ops.users.disable
      tags: [ops.users]
      summary: 禁用用户（需已批准审批单）
      x-rbac: [ops:users:write]
      x-idempotent: false
      x-tenant-scope: platform
      x-async: false
      x-billing: false
      parameters:
        - name: user_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ApprovalActionRequest' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MemberSummary' }
        default:
          $ref: '#/components/responses/Error'

  /api/v1/ops/users/{user_id}/enable:
    post:
      operationId: ops.users.enable
      tags: [ops.users]
      summary: 启用用户
      x-rbac: [ops:users:write]
      x-idempotent: false
      x-tenant-scope: platform
      x-async: false
      x-billing: false
      parameters:
        - name: user_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MemberSummary' }
        default:
          $ref: '#/components/responses/Error'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Keycloak 签发的 JWT，详见 api_design.md Section 04
    agentHubApiKey:
      type: apiKey
      in: header
      name: X-AgentHub-Api-Key
      description: |
        Headless 委托通道 API-key（`ahk_` 前缀，服务端 sha256 比对 api_keys.key_hash），
        与必带头 X-AgentHub-On-Behalf-Of 共同构成二方应用代用户调用（ADR-0042 / ADR-0049 D4）。

        **会话面为双凭据（ADR-0052 D1）**：`/api/v1/app/conversations*` 同时接受本方案与
        `bearerAuth`（平台 JWT，代表登录用户本人）。判定顺序——带本 Key 即走委托通道；
        只带 Bearer 走平台 JWT 通道并强制该操作的 `x-rbac` scope（ADR-0052 D2），且
        **禁止**携带 X-AgentHub-On-Behalf-Of（携带即 403 `AUTH_FORBIDDEN`，ADR-0052 D3）；
        两者都不带仍返回 401 `API_KEY_INVALID`（错误语义与既有集成方一致）。
    apiKeyHmac:
      type: apiKey
      in: header
      name: X-Signature
      description: |
        Webhook / Internal 签名校验（HMAC-SHA256）。具体签名材料与算法按 vendor / caller 区分，
        详见 endpoints/public-internal-webhook.md §C 与 api_design.md Section 9.4。
    mTLS:
      type: mutualTLS
      description: |
        Internal 端点专用：集群内调用方必须出示客户端证书（由内网 CA 签发），
        与 ServiceAccount JWT 共同构成双因子鉴权。详见 endpoints/public-internal-webhook.md §B。

  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      description: 写操作幂等键（UUID v4）；具体 endpoint 是否必填见 Markdown 契约。
      schema:
        type: string
        format: uuid
    OnBehalfOf:
      in: header
      name: X-AgentHub-On-Behalf-Of
      required: true
      description: |
        Headless 委托通道影子用户 users.id（API-key 租户内的目标用户）。
        非法 UUID → 422；非本租户或 status != 'active' → 403 AUTH_TENANT_MISMATCH。
      schema:
        type: string
        format: uuid
    AppId:
      in: header
      name: X-AgentHub-App-Id
      required: true
      description: SaaS 客户端目标 Manifest 的稳定 app_id（强制版本头）
      schema: { type: string }
    ManifestVersion:
      in: header
      name: X-AgentHub-Manifest-Version
      required: true
      description: 客户端当前持有的 Manifest 版本号（强制版本头）
      schema: { type: string }
    SdkName:
      in: header
      name: X-AgentHub-SDK-Name
      required: true
      description: SDK 名称（强制版本头）
      schema: { type: string, enum: [sdk-web, sdk-wechat, sdk-react] }
    SdkVersion:
      in: header
      name: X-AgentHub-SDK-Version
      required: true
      description: SDK 语义化版本（强制版本头）
      schema:
        type: string
        pattern: '^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$'
    RuntimeEventSchema:
      in: header
      name: X-AgentHub-Runtime-Event-Schema
      required: true
      description: RuntimeEvent Schema 主版本号（强制版本头）
      schema: { type: integer, minimum: 1 }
    SessionToken:
      in: header
      name: X-AgentHub-Session-Token
      required: true
      description: 由 saas.manifest.handshake 颁发的一次性会话令牌；TTL 失效返 409 HANDSHAKE_REQUIRED
      schema: { type: string }
    Cursor:
      in: query
      name: cursor
      required: false
      schema: { type: string }
    Limit:
      in: query
      name: limit
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    Sort:
      in: query
      name: sort
      required: false
      schema: { type: string }
    MfaToken:
      in: header
      name: X-MFA-Token
      required: false
      description: 高风险动作需要的 MFA 二次校验令牌；具体 endpoint 是否必填见 Markdown 契约。
      schema: { type: string }
    IfMatch:
      in: header
      name: If-Match
      required: false
      description: 乐观锁；填入资源最新的 `updated_at`（ISO-8601）。不一致返回 `409 OPTIMISTIC_LOCK_CONFLICT`。
      schema: { type: string }
    TenantIdPath:
      in: path
      name: tenant_id
      required: true
      schema: { type: string, format: uuid }
    UserIdPath:
      in: path
      name: user_id
      required: true
      schema: { type: string, format: uuid }
    RoleIdPath:
      in: path
      name: role_id
      required: true
      schema: { type: string, format: uuid }

  responses:
    Error:
      description: 错误响应（统一信封，详见 api_design.md Section 06）
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  schemas:
    # -------------------- 通用 --------------------
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, example: VALIDATION_ERROR }
            message: { type: string }
            message_i18n:
              type: object
              additionalProperties: { type: string }
            details:
              type: array
              items:
                type: object
                additionalProperties: true
            trace_id: { type: string }
            request_id: { type: string }

    # -------------------- Identity /me（IE-1） --------------------
    MeTenantSummary:
      type: object
      required: [id, name, slug, kind]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        kind:
          type: string
          enum: [personal, formal]
          description: personal = JIT 个人租户未升级；formal = 已升级正式租户

    MeResponse:
      type: object
      required:
        [id, tenant_id, name, email, avatar_url, primary_role, status, tenant, token_refresh_required]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        name: { type: string }
        email: { type: string, nullable: true }
        avatar_url: { type: string, nullable: true }
        primary_role: { type: string }
        status: { type: string }
        tenant: { $ref: '#/components/schemas/MeTenantSummary' }
        token_refresh_required:
          type: boolean
          description: JIT 或 claim 回写后为 true，客户端应刷新 access token

    MePatchRequest:
      type: object
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 128 }
        avatar_url: { type: string, format: uri, nullable: true }
      description: 白名单字段；未列字段一律拒绝（additionalProperties=false）

    # -------------------- Identity tenants / invites / ops（IE-2 / IE-4） --------------------
    TenantSummary:
      type: object
      required: [id, name, slug, status, kind]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        status: { type: string }
        kind:
          type: string
          enum: [personal, formal]
        token_refresh_required:
          type: boolean
          description: 升级 / claim 回写后为 true
        member_count: { type: integer }
        data_region: { type: string, nullable: true }

    TenantUpgradeRequest:
      type: object
      required: [name, slug]
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 128 }
        slug: { type: string, minLength: 2, maxLength: 64 }

    MemberSummary:
      type: object
      required: [id, name, primary_role, status]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        name: { type: string }
        email: { type: string, nullable: true }
        primary_role: { type: string }
        status: { type: string }

    InviteCreateRequest:
      type: object
      additionalProperties: false
      properties:
        role_code: { type: string, minLength: 1, maxLength: 64, default: saas_user }
        max_uses: { type: integer, minimum: 1, maximum: 100, default: 1 }
        expires_in_hours: { type: integer, minimum: 1, maximum: 720, default: 72 }

    InviteResponse:
      type: object
      required: [id, code, role_code, max_uses, use_count, tenant_id]
      properties:
        id: { type: string, format: uuid }
        code: { type: string }
        role_code: { type: string }
        max_uses: { type: integer }
        use_count: { type: integer }
        expires_at: { type: string, format: date-time, nullable: true }
        tenant_id: { type: string, format: uuid }

    M2mClientCreateRequest:
      type: object
      required: [label]
      additionalProperties: false
      properties:
        label:
          type: string
          minLength: 1
          maxLength: 64
          description: 凭据用途标签（展示名）

    M2mClientSummary:
      type: object
      required: [client_uuid, client_id, label, status, created_at]
      properties:
        client_uuid: { type: string, description: Keycloak client 内部 UUID }
        client_id: { type: string, description: client_credentials 登录用 clientId }
        label: { type: string }
        status: { type: string, enum: [active, revoked] }
        created_at: { type: string, format: date-time }
        rotated_at: { type: string, format: date-time, nullable: true }

    M2mClientSecret:
      type: object
      required: [client_uuid, client_id, client_secret]
      properties:
        client_uuid: { type: string }
        client_id: { type: string }
        client_secret: { type: string, description: 明文 secret，仅创建/轮转响应返回一次 }
        rotated_at: { type: string, format: date-time, nullable: true }

    OpsTenantListResponse:
      type: object
      required: [items, has_more, offset, limit]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/TenantSummary' }
        has_more: { type: boolean }
        offset: { type: integer }
        limit: { type: integer }

    ApprovalActionRequest:
      type: object
      required: [approval_ticket_id]
      additionalProperties: false
      properties:
        approval_ticket_id: { type: string, format: uuid }

    AccountDeleteResponse:
      type: object
      required: [id, status]
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [deleted] }
        idempotent: { type: boolean }
        remaining_memberships: { type: integer, description: "Number of non-deleted memberships remaining after this deletion" }
        keycloak_disabled: { type: boolean, description: "Whether the Keycloak account was disabled (only true when last membership was removed)" }

    PaginationMeta:
      type: object
      required: [has_more]
      properties:
        next_cursor: { type: string, nullable: true }
        has_more: { type: boolean }

    AsyncTaskAccepted:
      type: object
      required: [task_id, status_url]
      properties:
        task_id: { type: string }
        status_url: { type: string, format: uri-reference }

    # -------------------- SOP --------------------
    Sop:
      type: object
      required:
        - id
        - tenant_id
        - project_id
        - slug
        - name
        - status
        - default_timeout_seconds
        - tags
        - version
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        slug: { type: string }
        name: { type: string }
        description: { type: string, nullable: true }
        status:
          type: string
          enum: [designing, debugging, published, disabled, archived]
        current_version_id: { type: string, format: uuid, nullable: true }
        default_timeout_seconds: { type: integer, minimum: 1 }
        tags:
          type: array
          items: { type: string }
        owner_id: { type: string, format: uuid, nullable: true }
        version: { type: integer, minimum: 0 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    SopCreateRequest:
      type: object
      required: [project_id, slug, name]
      properties:
        project_id: { type: string, format: uuid }
        slug:
          type: string
          pattern: '^[a-z0-9-]{3,64}$'
        name: { type: string, maxLength: 200 }
        description: { type: string }
        default_timeout_seconds: { type: integer, minimum: 1, default: 14400 }
        tags:
          type: array
          items: { type: string }
          default: []
        owner_id: { type: string, format: uuid }
      example:
        project_id: 7f3a9b1c-2d8e-4f5a-9b1c-2d8e4f5a9b1c
        slug: customer-refund-sop
        name: 客户退款 SOP
        description: 处理客户退款的标准操作流程
        default_timeout_seconds: 14400
        tags: [refund, customer-service]
        owner_id: 5b8c1d3e-9f0a-4d6b-8e2c-1a7f4b9d3e5c

    SopUpdateRequest:
      type: object
      required: [version]
      properties:
        name: { type: string }
        description: { type: string, nullable: true }
        tags:
          type: array
          items: { type: string }
        owner_id: { type: string, format: uuid, nullable: true }
        default_timeout_seconds: { type: integer, minimum: 1 }
        version: { type: integer, minimum: 0 }

    SopPublishRequest:
      type: object
      required: [version_id, version]
      properties:
        version_id: { type: string }
        release_notes: { type: string }
        version: { type: integer, minimum: 0 }
      example:
        version_id: v-2026-05-14-01
        release_notes: 修复退款金额上限校验逻辑
        version: 3

    SopListResponse:
      allOf:
        - $ref: '#/components/schemas/PaginationMeta'
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/Sop' }

    SopVersion:
      type: object
      description: SOP 版本读模型（对齐后端 SopVersionOut，Tier 1 T1-1）
      required:
        - id
        - sop_id
        - version
        - workflow_spec
        - spec_hash
        - status
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        sop_id: { type: string, format: uuid }
        version: { type: integer, minimum: 1 }
        workflow_spec:
          type: object
          additionalProperties: true
          description: WorkflowSpec DSL（单一事实源 = backend workflow_spec.py / L2-A §3.1-3.2）
        logicflow_export:
          type: object
          additionalProperties: true
          nullable: true
          description: LogicFlow 画布布局导出（坐标等 UI 元数据）
        spec_hash: { type: string }
        change_description: { type: string, nullable: true }
        status:
          type: string
          enum: [draft, published, deprecated]
        published_at: { type: string, format: date-time, nullable: true }
        deprecated_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    SopExportPackage:
      type: object
      required: [schema_version, sop, versions]
      properties:
        schema_version: { type: string, example: '1.0' }
        sop: { $ref: '#/components/schemas/Sop' }
        versions:
          type: array
          items:
            type: object
            additionalProperties: true

    # -------------------- Agent --------------------
    Agent:
      type: object
      required:
        - id
        - tenant_id
        - name
        - status
        - version
        - created_at
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        model_group_id: { type: string, format: uuid }
        system_prompt: { type: string, nullable: true }
        tags:
          type: array
          items: { type: string }
        status:
          type: string
          enum: [active, archived]
          default: active
          description: >-
            CU3-1 目录生命周期状态（ADR-0053 D1）。归档 = 删除的默认语义：
            不出现在缺省列表、不可开新会话，既有会话与积分流水只读保留，可反归档。
            （此前本字段声明的 `designing/debugging/published/disabled` 从未实现，
            发布态由 `agent_versions.status` 承载。）
        agent_type:
          type: string
          enum: [sop_node, conversation]
          default: sop_node
          description: CV3-1 判别列——目录合一、通道分离（program §三）
        current_version_id: { type: string, format: uuid, nullable: true }
        draft_version_id: { type: string, format: uuid, nullable: true }
        version: { type: integer, minimum: 0 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ChatDryRunRequest:
      type: object
      required: [message]
      properties:
        message: { type: string, minLength: 1, maxLength: 8000 }

    ChatDryRunResult:
      type: object
      description: 会话 Agent chat 预览结果（CV5-2）
      required:
        - assistant_text
        - points_per_turn
        - guardrails
        - kb_count
        - tool_count
        - sop_allowlist
        - card_types_enabled
      properties:
        assistant_text: { type: string }
        points_per_turn: { type: integer }
        guardrails:
          type: object
          additionalProperties: true
        kb_count: { type: integer }
        tool_count: { type: integer }
        sop_allowlist:
          type: array
          items: { type: string }
        card_types_enabled:
          type: array
          items: { type: string }

    AgentConversationGuardrails:
      type: object
      description: 会话资源护栏（ADR-0050 D3，与单价强制配对，五项全必填）
      required:
        - max_input_tokens
        - max_attachments
        - max_tool_calls
        - max_sop_invocations
        - turn_timeout_seconds
      properties:
        max_input_tokens: { type: integer, minimum: 1 }
        max_attachments: { type: integer, minimum: 0 }
        max_tool_calls: { type: integer, minimum: 0 }
        max_sop_invocations: { type: integer, minimum: 0 }
        turn_timeout_seconds: { type: integer, minimum: 1 }
        max_images_per_turn:
          type: integer
          minimum: 1
          nullable: true
          default: null
          description: MM2-2（ADR-0055 D4/D5）可选图片工具上限；缺席 = generate_image 内置工具不注册（禁用）；文生图成本并入轮次（无单图扣点）。

    AgentConversationConfig:
      type: object
      description: >-
        发布快照 conversation 块（program §五）——仅 agent_type=conversation
        的 Agent 发布时必填；单价与护栏强制配对（CV3-1 发布校验）。
      required: [points_per_turn, guardrails]
      properties:
        points_per_turn: { type: integer, minimum: 1 }
        guardrails: { $ref: '#/components/schemas/AgentConversationGuardrails' }
        space_permissions:
          type: object
          properties:
            user_space:
              type: string
              enum: [read, read_write]
              default: read_write
            project_space:
              type: string
              enum: [read]
              default: read
        sop_allowlist:
          type: array
          items: { type: string }
          default: []
        sop_budget_per_conversation:
          type: integer
          minimum: 0
          nullable: true
        sop_estimated_points_per_call:
          type: integer
          minimum: 0
          default: 0
        idle_timeout_seconds: { type: integer, minimum: 1, default: 1800 }
        card_types_enabled:
          type: array
          items:
            type: string
            enum: [choice_card]
          default: []

    AgentRegisterRequest:
      type: object
      required: [name, model_group_id]
      properties:
        name: { type: string, maxLength: 200 }
        description: { type: string }
        model_group_id: { type: string, format: uuid }
        agent_type:
          type: string
          enum: [sop_node, conversation]
          default: sop_node
        system_prompt: { type: string }
        tags:
          type: array
          items: { type: string }
          default: []
        draft_spec:
          type: object
          properties:
            tools:
              type: array
              items: { type: object, additionalProperties: true }
            skills:
              type: array
              items: { type: object, additionalProperties: true }
            knowledge_bases:
              type: array
              items: { type: object, additionalProperties: true }
      example:
        name: 客服 Agent
        description: 处理客户售后咨询的 LLM Agent
        model_group_id: 5b8c1d3e-9f0a-4d6b-8e2c-1a7f4b9d3e5c
        system_prompt: 你是一名专业的客户服务 Agent，遵循公司 SOP 给出响应。
        tags: [customer-service, refund]
        draft_spec:
          tools: []
          skills: []

    AgentUpdateRequest:
      type: object
      description: >-
        CU3-1（ADR-0053 D4）——Agent 壳的部分更新。全部字段可选，
        但请求体不得为空对象（空 body → 422）；未出现的字段保持原值。
      minProperties: 1
      properties:
        name: { type: string, maxLength: 200 }
        description: { type: string, nullable: true }
        category:
          type: string
          enum: [builder, runtime, system, bench]
        agent_type:
          type: string
          enum: [sop_node, conversation]
          description: 仅在该 Agent 尚无任何版本时可改；已有版本 → 409 AGENT_TYPE_IMMUTABLE
        status:
          type: string
          enum: [active, archived]
          description: 置 active 即反归档
      example:
        name: 售后陪练助手
        description: 面向新员工的售后话术陪练

    AgentDeleteConflictResponse:
      type: object
      description: >-
        硬删守卫拒绝时的响应体（统一 Envelope 形状，development_standard §2.3.1）。
        `data.conflicts` 至少含一项，用户据此判断下一步（撤版本 / 保留会话走归档 / 退出团队）。
      required: [code, message, data]
      properties:
        code:
          type: string
          enum: [AGENT_DELETE_CONFLICT]
        message: { type: string }
        data:
          type: object
          required: [conflicts]
          properties:
            conflicts:
              type: array
              minItems: 1
              items:
                type: string
                enum: [published_versions, conversations, team_memberships]
      example:
        code: AGENT_DELETE_CONFLICT
        message: 'Agent cannot be hard-deleted: conversations, published_versions'
        data:
          conflicts: [published_versions, conversations]

    AgentPublishRequest:
      type: object
      required: [version]
      properties:
        release_notes: { type: string }
        version: { type: integer, minimum: 0 }
        conversation:
          allOf:
            - $ref: '#/components/schemas/AgentConversationConfig'
          description: 仅 agent_type=conversation 的 Agent 发布时必填（CV3-1）

    AgentListResponse:
      allOf:
        - $ref: '#/components/schemas/PaginationMeta'
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/Agent' }

    ConversationAgentCatalogItem:
      type: object
      description: >-
        用户侧会话 Agent 目录项（CU3-2，对齐后端 ConversationAgentCatalogItem）。
        安全收敛：恰好五字段——人设 / 工具绑定 / SOP 白名单 / 护栏细节
        即使在发布快照中存在也绝不出现在本响应。
      required: [id, name, description, points_per_turn, idle_timeout_seconds]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        points_per_turn:
          type: integer
          minimum: 1
          description: 每轮积分单价（发布快照 conversation 块）
        idle_timeout_seconds:
          type: integer
          minimum: 1
          description: 会话空闲闭合阈值秒（发布快照 conversation 块）

    ConversationAgentCatalogResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ConversationAgentCatalogItem' }

    AgentVersion:
      type: object
      description: Agent 版本读模型（对齐后端 AgentVersionOut，Tier 1 T1-1）
      required:
        - id
        - agent_id
        - version
        - status
        - config_snapshot_jsonb
        - published_at
      properties:
        id: { type: string, format: uuid }
        agent_id: { type: string, format: uuid }
        version: { type: integer, minimum: 1 }
        status:
          type: string
          enum: [draft, published, deprecated]
        change_description: { type: string, nullable: true }
        config_snapshot_jsonb:
          type: object
          additionalProperties: true
          description: 发布时冻结的配置快照（core / routing / bindings）
        published_at: { type: string, format: date-time }

    AgentTeamMember:
      type: object
      description: Agent 团队成员（对齐后端 AgentTeamMemberOut，Tier 1 T1-1）
      required: [id, team_id, agent_id, role_in_team, weight, order_index]
      properties:
        id: { type: string, format: uuid }
        team_id: { type: string, format: uuid }
        agent_id: { type: string, format: uuid }
        role_in_team: { type: string }
        weight: { type: integer }
        order_index: { type: integer }

    AgentTeam:
      type: object
      description: Agent 团队读模型（对齐后端 AgentTeamOut，Tier 1 T1-1）
      required: [id, tenant_id, project_id, name, collaboration_mode, members]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        name: { type: string }
        collaboration_mode:
          type: string
          enum: [parallel_voting, sequential_pipeline, host_participant]
        termination_rules_jsonb:
          type: object
          additionalProperties: true
        members:
          type: array
          items: { $ref: '#/components/schemas/AgentTeamMember' }

    AgentSkillBindingUpsertRequest:
      type: object
      properties:
        priority: { type: integer, minimum: 0, default: 0 }
        config:
          type: object
          additionalProperties: true
        enabled: { type: boolean, default: true }

    AgentSkillBinding:
      type: object
      required: [agent_id, skill_id, enabled]
      properties:
        agent_id: { type: string, format: uuid }
        skill_id: { type: string, format: uuid }
        priority: { type: integer }
        config:
          type: object
          additionalProperties: true
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # -------------------- AppManifest --------------------
    AppManifest:
      type: object
      required: [id, tenant_id, project_id, app_id, version, status]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        app_id: { type: string, description: 跨版本稳定的对外标识 }
        version: { type: integer, minimum: 1, description: Manifest 自身版本号 }
        status:
          type: string
          enum: [draft, published, deprecated, archived]
        name: { type: string }
        description: { type: string, nullable: true }
        supported_channels:
          type: array
          items:
            type: string
            enum: [web, wechat_mini, android, ios]
        frontend_features:
          type: array
          items: { type: string }
        runtime_event_schema_version: { type: integer, minimum: 1 }
        sdk_compat_range:
          type: object
          additionalProperties: { type: string }
        public_snapshot:
          type: object
          additionalProperties: true
          nullable: true
        private_snapshot:
          type: object
          additionalProperties: true
          nullable: true
        published_at: { type: string, format: date-time, nullable: true }
        deprecated_at: { type: string, format: date-time, nullable: true }
        latest_pdk:
          allOf:
            - $ref: '#/components/schemas/PdkArtifact'
          nullable: true
        bindings:
          type: array
          items: { $ref: '#/components/schemas/WorkflowBinding' }
        channels:
          type: array
          items: { $ref: '#/components/schemas/ChannelConfig' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AppManifestListResponse:
      type: object
      required: [items, has_more]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/AppManifest' }
        next_cursor: { type: string, nullable: true }
        has_more: { type: boolean }

    AppManifestCreateRequest:
      type: object
      required: [project_id, app_id, name, supported_channels]
      properties:
        project_id: { type: string, format: uuid }
        app_id:
          type: string
          minLength: 3
          maxLength: 64
          pattern: '^[a-z][a-z0-9_]*$'
        name: { type: string, minLength: 1, maxLength: 128 }
        description: { type: string, nullable: true }
        supported_channels:
          type: array
          minItems: 1
          items:
            type: string
            enum: [web, wechat_mini, android, ios]
        frontend_features:
          type: array
          items:
            type: string
            pattern: '^[a-z0-9_]+$'
        sdk_compat_range:
          type: object
          additionalProperties: { type: string }
          description: 形如 {"core":">=1.0.0 <2.0.0"}
      example:
        project_id: 7f3a9b1c-2d8e-4f5a-9b1c-2d8e4f5a9b1c
        app_id: refund_assistant
        name: 退款助手
        description: 帮助坐席快速处理退款工单
        supported_channels: [web, wechat_mini]
        frontend_features: [refund_form, evidence_uploader]
        sdk_compat_range:
          core: '>=1.0.0 <2.0.0'

    AppManifestUpdateRequest:
      type: object
      required: [version]
      properties:
        version: { type: integer, description: 乐观锁，需与 AppManifest.version 一致 }
        name: { type: string }
        description: { type: string, nullable: true }
        supported_channels:
          type: array
          items:
            type: string
            enum: [web, wechat_mini, android, ios]
        frontend_features:
          type: array
          items: { type: string }
        sdk_compat_range:
          type: object
          additionalProperties: { type: string }

    AppManifestPublishRequest:
      type: object
      required: [version]
      properties:
        version: { type: integer, description: 乐观锁 }
        publish_notes: { type: string, nullable: true }
        skip_pdk_generation:
          type: boolean
          default: false
          description: 仅供运营降级使用，正常发布必须生成 PDK
      example:
        version: 5
        publish_notes: 新增飞书审批渠道，扩展 webhook 出口
        skip_pdk_generation: false

    AppManifestPublishResponse:
      type: object
      required: [manifest, pdk_task]
      properties:
        manifest: { $ref: '#/components/schemas/AppManifest' }
        pdk_task:
          type: object
          required: [task_id, status]
          properties:
            task_id: { type: string }
            status:
              type: string
              enum: [generating, skipped]

    AppManifestDeprecateRequest:
      type: object
      required: [version]
      properties:
        version: { type: integer }
        reason: { type: string, nullable: true }
        redirect_to_version:
          type: integer
          nullable: true
          description: Handshake 阶段返回给前端的兼容升级建议

    # -------------------- WorkflowBinding --------------------
    WorkflowBinding:
      type: object
      required: [id, manifest_id, frontend_feature_key, workflow_version_id, validation_status]
      properties:
        id: { type: string, format: uuid }
        manifest_id: { type: string, format: uuid }
        frontend_feature_key: { type: string }
        workflow_key: { type: string }
        workflow_version_id: { type: string, format: uuid }
        input_schema:
          type: object
          additionalProperties: true
        output_schema:
          type: object
          additionalProperties: true
        permission_rules:
          type: object
          additionalProperties: true
        billing_rules:
          type: object
          additionalProperties: true
        validation_status:
          type: string
          enum: [unchecked, valid, invalid]
        validated_at: { type: string, format: date-time, nullable: true }
        version: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WorkflowBindingUpsertRequest:
      type: object
      required: [workflow_key, workflow_version_id]
      properties:
        workflow_key: { type: string }
        workflow_version_id: { type: string, format: uuid }
        input_schema:
          type: object
          additionalProperties: true
        output_schema:
          type: object
          additionalProperties: true
        permission_rules:
          type: object
          additionalProperties: true
        billing_rules:
          type: object
          additionalProperties: true
        validate_now:
          type: boolean
          default: true

    # -------------------- ChannelConfig --------------------
    ChannelConfig:
      type: object
      required: [id, manifest_id, channel, status]
      properties:
        id: { type: string, format: uuid }
        manifest_id: { type: string, format: uuid }
        channel:
          type: string
          enum: [web, wechat_mini, android, ios]
        login_config:
          type: object
          additionalProperties: true
        upload_config:
          type: object
          additionalProperties: true
        payment_config:
          type: object
          additionalProperties: true
        event_channel:
          type: string
          enum: [sse, websocket]
        grayscale_config:
          type: object
          additionalProperties: true
        status:
          type: string
          enum: [active, paused]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ChannelConfigUpsertRequest:
      type: object
      properties:
        login_config:
          type: object
          additionalProperties: true
        upload_config:
          type: object
          additionalProperties: true
        payment_config:
          type: object
          additionalProperties: true
        event_channel:
          type: string
          enum: [sse, websocket]
        grayscale_config:
          type: object
          additionalProperties: true
        status:
          type: string
          enum: [active, paused]
          default: active

    # -------------------- PDK --------------------
    PdkArtifact:
      type: object
      required: [id, app_id, manifest_version, pdk_version, status]
      properties:
        id: { type: string, format: uuid }
        app_id: { type: string }
        manifest_version: { type: integer }
        pdk_version: { type: string, description: 语义化版本，如 1.0.1 }
        runtime_event_schema_version: { type: integer }
        workflow_versions:
          type: object
          additionalProperties: { type: string }
        sdk_compat_range:
          type: object
          additionalProperties: { type: string }
        status:
          type: string
          enum: [generating, ready, failed, deprecated]
        bundle_size_bytes: { type: integer, nullable: true }
        checksum_sha256: { type: string, nullable: true }
        generator_version: { type: string }
        generated_at: { type: string, format: date-time, nullable: true }
        deprecated_at: { type: string, format: date-time, nullable: true }
        error_reason: { type: string, nullable: true }

    PdkArtifactListResponse:
      type: object
      required: [items, has_more]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PdkArtifact' }
        next_cursor: { type: string, nullable: true }
        has_more: { type: boolean }

    PdkGenerateRequest:
      type: object
      properties:
        bump:
          type: string
          enum: [patch, minor, major]
          default: patch
        generator_version: { type: string }
        include_examples:
          type: array
          items: { type: string }
        sdk_compat_range:
          type: object
          additionalProperties: { type: string }
        notes: { type: string, nullable: true }
      example:
        bump: minor
        generator_version: 2026.05.0
        include_examples: [refund-flow]
        sdk_compat_range:
          core: '>=1.0.0 <2.0.0'
        notes: 升级到 channel v2 协议

    PdkGenerateAccepted:
      type: object
      required: [task_id, manifest_id, pdk_version, status]
      properties:
        task_id: { type: string }
        manifest_id: { type: string, format: uuid }
        manifest_version: { type: integer }
        pdk_version: { type: string }
        status:
          type: string
          enum: [generating]
        status_url: { type: string, format: uri-reference }

    PdkDownloadUrlResponse:
      type: object
      required: [pdk_version, bundle_url, expires_at]
      properties:
        pdk_version: { type: string }
        manifest_version: { type: integer }
        bundle_url: { type: string, format: uri }
        checksum_sha256: { type: string }
        bundle_size_bytes: { type: integer }
        expires_at: { type: string, format: date-time }

    AgentToolBindingUpsertRequest:
      type: object
      properties:
        max_calls_per_run: { type: integer, minimum: 0 }
        timeout_seconds: { type: integer, minimum: 1 }
        config_override:
          type: object
          additionalProperties: true
        enabled: { type: boolean, default: true }

    AgentToolBinding:
      type: object
      required: [agent_id, tool_id, enabled]
      properties:
        agent_id: { type: string, format: uuid }
        tool_id: { type: string, format: uuid }
        max_calls_per_run: { type: integer }
        timeout_seconds: { type: integer }
        config_override:
          type: object
          additionalProperties: true
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AgentKnowledgeBindingUpsertRequest:
      type: object
      properties:
        top_k: { type: integer, minimum: 1, default: 5 }
        score_threshold:
          type: string
          description: Decimal as string，避免浮点失真
          example: '0.65'
        filter_tags:
          type: array
          items: { type: string }
        enabled: { type: boolean, default: true }

    AgentKnowledgeBinding:
      type: object
      required: [agent_id, kb_id, enabled]
      properties:
        agent_id: { type: string, format: uuid }
        kb_id: { type: string, format: uuid }
        top_k: { type: integer }
        score_threshold: { type: string }
        filter_tags:
          type: array
          items: { type: string }
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # -------------------- Knowledge --------------------
    KnowledgeBaseChunkStrategy:
      type: object
      required: [type, chunk_size, overlap]
      properties:
        type:
          type: string
          enum: [recursive, fixed, semantic]
        chunk_size: { type: integer, minimum: 64 }
        overlap: { type: integer, minimum: 0 }
        separators:
          type: array
          items: { type: string }

    KnowledgeBaseRetrievalDefaults:
      type: object
      properties:
        top_k: { type: integer, minimum: 1, maximum: 50, default: 4 }
        ef_search: { type: integer, minimum: 10, maximum: 400, default: 40 }
        score_threshold: { type: number, nullable: true }
        hybrid:
          type: boolean
          default: false
          description: '检索默认开关（设计态；实装面 = KnowledgeSearchRequest.hybrid，见其 description）'

    KnowledgeBase:
      type: object
      required:
        - id
        - tenant_id
        - scope
        - slug
        - name
        - embedding_model_id
        - embedding_dimensions
        - vector_metric
        - status
        - version
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        scope:
          type: string
          enum: [project, tenant]
        project_id: { type: string, format: uuid, nullable: true }
        slug:
          type: string
          pattern: '^[a-z0-9_-]{3,64}$'
        name: { type: string, maxLength: 200 }
        description: { type: string, nullable: true }
        embedding_model_id: { type: string, description: '写入后冻结' }
        embedding_dimensions: { type: integer, description: '写入后冻结' }
        vector_metric:
          type: string
          enum: [cosine, l2, ip]
          description: '写入后冻结'
        chunk_strategy: { $ref: '#/components/schemas/KnowledgeBaseChunkStrategy' }
        retrieval_defaults: { $ref: '#/components/schemas/KnowledgeBaseRetrievalDefaults' }
        visibility:
          type: string
          enum: [private, tenant]
        owner_id: { type: string, format: uuid, nullable: true }
        tags:
          type: array
          items: { type: string }
        status:
          type: string
          enum: [active, building, disabled, archived]
        doc_count: { type: integer, minimum: 0 }
        chunk_count: { type: integer, minimum: 0 }
        total_bytes: { type: integer, minimum: 0 }
        stats:
          type: object
          description: '可选；include=stats 时返回流水线进度直方图'
          additionalProperties: { type: integer }
        version: { type: integer, minimum: 0 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    KnowledgeBaseCreateRequest:
      type: object
      required:
        - scope
        - slug
        - name
        - embedding_model_id
        - embedding_dimensions
      properties:
        scope:
          type: string
          enum: [project, tenant]
        project_id: { type: string, format: uuid }
        slug:
          type: string
          pattern: '^[a-z0-9_-]{3,64}$'
        name: { type: string, maxLength: 200 }
        description: { type: string }
        embedding_model_id: { type: string }
        embedding_dimensions: { type: integer }
        vector_metric:
          type: string
          enum: [cosine, l2, ip]
          default: cosine
        chunk_strategy: { $ref: '#/components/schemas/KnowledgeBaseChunkStrategy' }
        retrieval_defaults: { $ref: '#/components/schemas/KnowledgeBaseRetrievalDefaults' }
        visibility:
          type: string
          enum: [private, tenant]
          default: private
        owner_id: { type: string, format: uuid }
        tags:
          type: array
          items: { type: string }
          default: []
      example:
        scope: project
        project_id: 7f3a9b1c-2d8e-4f5a-9b1c-2d8e4f5a9b1c
        slug: refund-policy
        name: 退款政策知识库
        description: 公司退款相关政策文档集合
        embedding_model_id: text-embedding-3-large
        embedding_dimensions: 3072
        vector_metric: cosine
        visibility: tenant
        tags: [refund, policy]

    KnowledgeBaseUpdateRequest:
      type: object
      description: 'JSON Merge Patch 语义；冻结字段（embedding_*/vector_metric）不允许出现'
      required: [version]
      properties:
        name: { type: string, maxLength: 200 }
        description: { type: string, nullable: true }
        chunk_strategy: { $ref: '#/components/schemas/KnowledgeBaseChunkStrategy' }
        retrieval_defaults: { $ref: '#/components/schemas/KnowledgeBaseRetrievalDefaults' }
        visibility:
          type: string
          enum: [private, tenant]
        tags:
          type: array
          items: { type: string }
        owner_id: { type: string, format: uuid, nullable: true }
        version: { type: integer, minimum: 0 }

    KnowledgeBaseListResponse:
      allOf:
        - $ref: '#/components/schemas/PaginationMeta'
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/KnowledgeBase' }

    KnowledgeBaseRebuildRequest:
      type: object
      required: [version]
      properties:
        reason: { type: string, maxLength: 500 }
        scope:
          type: string
          enum: [all, failed_only, obsolete_only]
          default: all
        version: { type: integer, minimum: 0 }

    KnowledgeDocumentLastError:
      type: object
      required: [code, stage, message]
      properties:
        code: { type: string }
        stage:
          type: string
          enum: [parsing, chunking, embedding]
        message: { type: string }
        traceback_ref: { type: string, nullable: true }

    KnowledgeDocument:
      type: object
      required:
        - id
        - knowledge_base_id
        - source_type
        - file_type
        - vectorization_status
        - version
      properties:
        id: { type: string, format: uuid }
        knowledge_base_id: { type: string, format: uuid }
        source_type:
          type: string
          enum: [upload, url, webpage, conversation_archive, api_sync]
        file_type: { type: string }
        title: { type: string }
        file_url: { type: string, nullable: true }
        file_bytes: { type: integer, nullable: true }
        content_hash: { type: string, nullable: true }
        language: { type: string, nullable: true, description: 'BCP-47' }
        metadata:
          type: object
          additionalProperties: true
        vectorization_status:
          type: string
          enum: [pending, queued, parsing, chunking, embedding, completed, failed, obsolete]
        chunk_count: { type: integer, minimum: 0 }
        token_count: { type: integer, minimum: 0 }
        retry_count: { type: integer, minimum: 0 }
        queued_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        last_error:
          oneOf:
            - $ref: '#/components/schemas/KnowledgeDocumentLastError'
            - type: 'null'
        version: { type: integer, minimum: 0 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    KnowledgeDocumentRegisterRequest:
      type: object
      required: [source_type]
      description: 'application/json 模式（外部源登记）；上传文件请走 multipart/form-data'
      properties:
        source_type:
          type: string
          enum: [url, webpage, conversation_archive, api_sync]
        source_uri: { type: string, format: uri }
        file_type: { type: string }
        title: { type: string }
        metadata:
          type: object
          additionalProperties: true
        language: { type: string, description: 'BCP-47' }

    KnowledgeDocumentRetryRequest:
      type: object
      required: [version]
      properties:
        from_stage:
          type: string
          enum: [parsing, chunking, embedding]
          default: parsing
        version: { type: integer, minimum: 0 }

    KnowledgeDocumentListResponse:
      allOf:
        - $ref: '#/components/schemas/PaginationMeta'
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/KnowledgeDocument' }

    KnowledgeChunk:
      type: object
      required: [id, document_id, chunk_index, content_tokens]
      properties:
        id: { type: string }
        document_id: { type: string, format: uuid }
        chunk_index: { type: integer, minimum: 0 }
        content: { type: string, description: 'include_content=false 时不返回' }
        content_tokens: { type: integer, minimum: 0 }
        metadata:
          type: object
          additionalProperties: true

    KnowledgeChunkListResponse:
      allOf:
        - $ref: '#/components/schemas/PaginationMeta'
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/KnowledgeChunk' }

    KnowledgeSearchRequest:
      type: object
      required: [query]
      properties:
        query: { type: string, minLength: 1, maxLength: 4000 }
        top_k: { type: integer, minimum: 1, maximum: 50 }
        ef_search: { type: integer, minimum: 10, maximum: 400 }
        score_threshold: { type: number, nullable: true }
        hybrid:
          type: boolean
          description: 'PE1-3 已实装：true = 向量 + CJK-bigram 全文双路召回经 RRF(k=60) 融合；score_threshold 此时作用于融合分；默认 false 走纯向量'
        filter:
          type: object
          properties:
            metadata:
              type: object
              additionalProperties: true
      example:
        query: 七天无理由退款的最长处理时长是多少?
        top_k: 5
        score_threshold: 0.6

    KnowledgeSearchHit:
      type: object
      required: [chunk_id, document_id, score]
      properties:
        chunk_id: { type: string }
        document_id: { type: string, format: uuid }
        document_title: { type: string }
        chunk_index: { type: integer, minimum: 0 }
        content: { type: string }
        chapter_path: { type: string, nullable: true, description: '结构感知分块章节面包屑（PE1-1）；legacy 分块为 null' }
        score: { type: number }
        metadata:
          type: object
          additionalProperties: true

    KnowledgeSearchResponse:
      type: object
      required: [kb_id, items]
      properties:
        kb_id: { type: string, format: uuid }
        embedding_model_id: { type: string }
        took_ms: { type: integer, minimum: 0 }
        items:
          type: array
          items: { $ref: '#/components/schemas/KnowledgeSearchHit' }

    # -------------------- Model Registry（Tier 0 T0-3，对齐 app/builder/schemas/model_registry.py） --------------------
    ModelGroup:
      type: object
      required: [id, tenant_id, name, routing_strategy, status, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        routing_strategy:
          type: string
          enum: [quality_first, cost_first, low_latency, custom]
        status:
          type: string
          enum: [active, disabled]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ModelGroupCreateRequest:
      type: object
      required: [name]
      properties:
        name: { type: string }
        description: { type: string }
        routing_strategy:
          type: string
          enum: [quality_first, cost_first, low_latency, custom]
          default: quality_first

    ModelRegisterRequest:
      type: object
      required: [group_id, vendor, name, context_max_tokens, api_key, pricing_input_per_1m_cents, pricing_output_per_1m_cents]
      properties:
        group_id: { type: string, format: uuid }
        vendor: { type: string }
        name: { type: string }
        model_type:
          type: string
          enum: [llm, embedding, tts, stt, reranker, image_gen, video_gen]
          default: llm
        context_max_tokens: { type: integer, minimum: 1 }
        api_key:
          type: string
          format: password
          description: 明文仅在 vault write_ref 时提取一次；不落库、不回显、不进日志
        api_url:
          type: string
          nullable: true
          description: 连通性探测目标（SSRF 门控）；不持久化
        pricing_input_per_1m_cents: { type: integer, minimum: 0 }
        pricing_output_per_1m_cents: { type: integer, minimum: 0 }
        unit_price: { type: number, nullable: true, description: '非 LLM 模态计费（ADR-0015）' }
        price_unit:
          type: string
          nullable: true
          enum: [token, char, second, minute, image, frame]
        capabilities:
          type: array
          items:
            type: string
            enum: [vision]
          default: []
          description: 'MM4-1（ADR-0056 D2）：模型能力标记；vision = 接受原生图片 content parts'

    Model:
      type: object
      required: [id, group_id, vendor, name, model_type, context_max_tokens, vault_secret_ref_id, pricing_input_per_1m_cents, pricing_output_per_1m_cents, status]
      properties:
        id: { type: string, format: uuid }
        group_id: { type: string, format: uuid }
        vendor: { type: string }
        name: { type: string }
        model_type: { type: string }
        context_max_tokens: { type: integer }
        vault_secret_ref_id:
          type: string
          format: uuid
          description: vault 引用（不透出明文 key）
        pricing_input_per_1m_cents: { type: integer }
        pricing_output_per_1m_cents: { type: integer }
        unit_price: { type: number, nullable: true }
        price_unit: { type: string, nullable: true }
        capabilities:
          type: array
          items: { type: string }
          default: []
          description: 'MM4-1（ADR-0056 D2）：能力标记回读（models.capabilities_jsonb）'
        status:
          type: string
          enum: [active, deprecated, disabled]
        warnings:
          type: array
          items: { type: string }
          default: []

    ModelRouteRequest:
      type: object
      required: [group_id, primary_model_id]
      properties:
        group_id: { type: string, format: uuid }
        primary_model_id: { type: string, format: uuid }
        fallback_model_id: { type: string, format: uuid, nullable: true }
        rate_limit:
          type: object
          additionalProperties: true
          default: {}

    ModelRoute:
      type: object
      required: [id, group_id, primary_model_id, rate_limit_jsonb, status]
      properties:
        id: { type: string, format: uuid }
        group_id: { type: string, format: uuid }
        primary_model_id: { type: string, format: uuid }
        fallback_model_id: { type: string, format: uuid, nullable: true }
        rate_limit_jsonb:
          type: object
          additionalProperties: true
        status:
          type: string
          enum: [active, disabled]

    ModelVendor:
      type: object
      required: [code, api_base, source, protocol_family]
      description: |
        生效中的 vendor→网关条目（MR1-1 只读面，MR2-1 扩展）。仅可解析出
        地址的 vendor 会出现；protocol_family 标注线协议族（适配器分发
        消费）；租户注册行 source=tenant 并携带行 id，置顶排列。
      properties:
        code: { type: string, maxLength: 64 }
        api_base: { type: string, maxLength: 512 }
        source:
          type: string
          enum: [builtin, env_override, tenant]
        protocol_family:
          type: string
          enum: [openai_compatible, dashscope]
        id: { type: string, format: uuid, nullable: true }

    ModelVendorRequest:
      type: object
      required: [code, api_base]
      properties:
        code:
          type: string
          maxLength: 64
          pattern: '^[a-z0-9][a-z0-9-]{0,63}$'
        api_base:
          type: string
          maxLength: 512
          pattern: '^https?://'
        protocol_family:
          type: string
          enum: [openai_compatible, dashscope]
          default: openai_compatible
        status:
          type: string
          enum: [active, disabled]
          default: active

    ModelVendorUpdateRequest:
      type: object
      description: 全可选（缺席即不动）；code 不可变。
      properties:
        api_base:
          type: string
          maxLength: 512
          pattern: '^https?://'
        protocol_family:
          type: string
          enum: [openai_compatible, dashscope]
        status:
          type: string
          enum: [active, disabled]

    ModelVendorRow:
      type: object
      required: [id, code, api_base, protocol_family, status, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        code: { type: string }
        api_base: { type: string }
        protocol_family: { type: string }
        status: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # -------------------- Operator — 运营看板 --------------------
    DashboardWindow:
      type: object
      required: [from, to]
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }

    DashboardOverviewCards:
      type: object
      properties:
        workflow_tasks:
          type: object
          properties:
            total: { type: integer }
            succeeded: { type: integer }
            failed: { type: integer }
            in_progress: { type: integer }
            success_rate:
              type: string
              description: 'Decimal as string，[0,1]'
        points:
          type: object
          properties:
            consumed: { type: integer }
            freezed: { type: integer }
            balance_total: { type: integer }
        ops_events:
          type: object
          properties:
            open: { type: integer }
            in_progress: { type: integer }
            critical_open: { type: integer }
            sla_breached: { type: integer }
        cost:
          type: object
          properties:
            model_call_amount_cents: { type: integer }
            budget_used_ratio:
              type: string
              description: 'Decimal as string，[0,1]'

    DashboardOverviewResponse:
      type: object
      required: [tenant_id, window, cards]
      properties:
        tenant_id: { type: string, format: uuid }
        window: { $ref: '#/components/schemas/DashboardWindow' }
        cards: { $ref: '#/components/schemas/DashboardOverviewCards' }
        compare:
          type: object
          nullable: true
          properties:
            window: { $ref: '#/components/schemas/DashboardWindow' }
            cards: { $ref: '#/components/schemas/DashboardOverviewCards' }

    DashboardMetricValue:
      type: object
      required: [key]
      properties:
        key: { type: string, description: '白名单指标 key（见 endpoints/operator-dashboard.md 备注）' }
        value:
          oneOf:
            - { type: number }
            - { type: integer }
            - { type: string, description: 'Decimal as string（如 success_rate）' }
        unit:
          type: string
          enum: [count, ratio, points, cents, ms, percent]
        dimensions:
          type: array
          nullable: true
          items:
            type: object
            additionalProperties: true

    DashboardMetricsResponse:
      type: object
      required: [window, metrics]
      properties:
        window: { $ref: '#/components/schemas/DashboardWindow' }
        metrics:
          type: array
          items: { $ref: '#/components/schemas/DashboardMetricValue' }

    DashboardTimeseriesPoint:
      type: object
      required: [ts]
      properties:
        ts: { type: string, format: date-time }
        value:
          oneOf:
            - { type: number }
            - { type: integer }
            - { type: string }
          nullable: true

    DashboardTimeseriesResponse:
      type: object
      required: [metric, granularity, points]
      properties:
        metric: { type: string }
        unit:
          type: string
          enum: [count, ratio, points, cents, ms, percent]
        granularity:
          type: string
          enum: [5m, 15m, 1h, 1d]
        points:
          type: array
          items: { $ref: '#/components/schemas/DashboardTimeseriesPoint' }

    # -------------------- Operator — OpsEvent --------------------
    OpsEvent:
      type: object
      required:
        - id
        - tenant_id
        - fingerprint
        - category
        - event_type
        - severity
        - status
        - summary
        - first_detected_at
        - last_detected_at
        - occurrence_count
        - version
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        fingerprint: { type: string }
        category:
          type: string
          description: '与 entities/09-ops-agent.md §2.1 category 字段一致'
        event_type: { type: string }
        severity:
          type: string
          enum: [info, warn, error, critical]
        status:
          type: string
          enum: [open, in_progress, resolved, dismissed, suppressed]
        summary: { type: string }
        details:
          type: object
          additionalProperties: true
        payload:
          type: object
          additionalProperties: true
          description: '可能携带 blob_url 预签名 URL（TTL 5 分钟）'
        source_event_ids:
          type: array
          items: { type: string }
        trace_ids:
          type: array
          items: { type: string }
        occurrence_count: { type: integer, minimum: 1 }
        first_detected_at: { type: string, format: date-time }
        last_detected_at: { type: string, format: date-time }
        sla_due_at: { type: string, format: date-time, nullable: true }
        acknowledged_at: { type: string, format: date-time, nullable: true }
        resolved_at: { type: string, format: date-time, nullable: true }
        dismissed_at: { type: string, format: date-time, nullable: true }
        assignee_id: { type: string, format: uuid, nullable: true }
        assignee_group: { type: string, nullable: true }
        auto_action_allowed: { type: boolean }
        escalation_level: { type: integer, minimum: 0 }
        resolution: { type: string, nullable: true }
        root_cause: { type: string, nullable: true }
        dismissed_reason:
          type: string
          nullable: true
          enum: [false_positive, duplicate, no_action_needed, other]
        version: { type: integer, minimum: 0 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    OpsEventListItem:
      type: object
      required:
        - id
        - fingerprint
        - category
        - event_type
        - severity
        - status
        - summary
        - first_detected_at
        - last_detected_at
        - occurrence_count
      properties:
        id: { type: string, format: uuid }
        fingerprint: { type: string }
        category: { type: string }
        event_type: { type: string }
        severity:
          type: string
          enum: [info, warn, error, critical]
        status:
          type: string
          enum: [open, in_progress, resolved, dismissed, suppressed]
        summary: { type: string }
        occurrence_count: { type: integer, minimum: 1 }
        first_detected_at: { type: string, format: date-time }
        last_detected_at: { type: string, format: date-time }
        sla_due_at: { type: string, format: date-time, nullable: true }
        assignee_id: { type: string, format: uuid, nullable: true }
        assignee_group: { type: string, nullable: true }
        auto_action_allowed: { type: boolean }
        trace_ids:
          type: array
          items: { type: string }

    OpsEventListResponse:
      allOf:
        - $ref: '#/components/schemas/PaginationMeta'
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/OpsEventListItem' }

    OpsEventAcknowledgeRequest:
      type: object
      required: [version]
      properties:
        comment: { type: string, maxLength: 2000 }
        version: { type: integer, minimum: 0 }
      example:
        comment: 已认领，开始排查支付网关日志
        version: 2

    OpsEventAssignRequest:
      type: object
      required: [version]
      properties:
        assignee_id: { type: string, format: uuid }
        assignee_group: { type: string }
        reason: { type: string, maxLength: 2000 }
        version: { type: integer, minimum: 0 }
      example:
        assignee_id: 44444444-4444-4444-4444-444444444444
        assignee_group: risk_oncall
        reason: 转交风控值班同事
        version: 2

    OpsEventResolveRequest:
      type: object
      required: [resolution, version]
      properties:
        resolution: { type: string, minLength: 1, maxLength: 2000 }
        root_cause: { type: string, maxLength: 2000 }
        linked_ticket_url: { type: string, format: uri }
        version: { type: integer, minimum: 0 }
      example:
        resolution: 调账完成，已与客户确认
        root_cause: 支付网关重复回调导致积分双发
        linked_ticket_url: https://oncall.example.com/tickets/OPS-2026-0513
        version: 3

    OpsEventDismissRequest:
      type: object
      required: [dismissed_reason, version]
      properties:
        dismissed_reason:
          type: string
          enum: [false_positive, duplicate, no_action_needed, other]
        comment: { type: string, maxLength: 2000 }
        version: { type: integer, minimum: 0 }

    # -------------------- Operator — OpsAction --------------------
    OpsAction:
      type: object
      required:
        - id
        - tenant_id
        - event_id
        - action_type
        - actor_type
        - outcome
        - started_at
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        event_id: { type: string, format: uuid }
        action_type:
          type: string
          description: '动作码白名单见 entities/09-ops-agent.md §3.4 与 endpoints/operator-dashboard.md approve_action 备注'
        actor_type:
          type: string
          enum: [auto, human, webhook]
        actor_id: { type: string, format: uuid, nullable: true }
        target_action_id:
          type: string
          format: uuid
          nullable: true
          description: 'lifecycle.approve / lifecycle.reject 类型时引用被审批的目标 OpsAction'
        params:
          type: object
          additionalProperties: true
        result:
          type: object
          additionalProperties: true
        outcome:
          type: string
          enum: [pending, success, failure, skipped, cancelled]
        requires_mfa: { type: boolean }
        mfa_verified: { type: boolean }
        audit_log_id: { type: string, format: uuid, nullable: true }
        started_at: { type: string, format: date-time }
        finished_at: { type: string, format: date-time, nullable: true }
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: '审批失效窗口（默认 30min）'
        created_at: { type: string, format: date-time }

    OpsActionListResponse:
      allOf:
        - $ref: '#/components/schemas/PaginationMeta'
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/OpsAction' }

    OpsActionApproveRequest:
      type: object
      properties:
        comment: { type: string, maxLength: 2000 }
        params_override:
          type: object
          additionalProperties: true
          description: '按 action_type 白名单允许覆盖的字段集合；超出白名单字段返回 400 VALIDATION_ERROR'
      example:
        comment: 已核对账单流水，批准退款
        params_override:
          refund_amount_cents: 9900

    OpsActionRejectRequest:
      type: object
      required: [reason]
      properties:
        reason: { type: string, minLength: 1, maxLength: 2000 }
        create_followup_event: { type: boolean, default: false }

    # -------------------- Operator — 长效积分批量发放 --------------------
    LongTermBatchItem:
      type: object
      required: [user_id, points]
      properties:
        user_id: { type: string, format: uuid }
        points: { type: integer, minimum: 1 }
        valid_from: { type: string, format: date-time, nullable: true }
        valid_until: { type: string, format: date-time, nullable: true }
        memo: { type: string, maxLength: 500, nullable: true }

    IssueLongTermBatchRequest:
      type: object
      required: [batch_ref, reason_code, items]
      properties:
        batch_ref:
          type: string
          minLength: 4
          maxLength: 64
          description: 业务侧批次号；与 Idempotency-Key 形成双锁（24h 唯一窗口）
        reason_code:
          type: string
          enum: [marketing_grant, compensation, contract_settlement, manual_grant, other]
        memo: { type: string, maxLength: 1000 }
        valid_from: { type: string, format: date-time, nullable: true }
        valid_until: { type: string, format: date-time, nullable: true }
        items:
          type: array
          minItems: 1
          maxItems: 1000
          items: { $ref: '#/components/schemas/LongTermBatchItem' }
      example:
        batch_ref: q2-promo-2026-05-14
        reason_code: marketing_grant
        memo: Q2 战略客户配额发放
        valid_from: '2026-05-14T00:00:00+08:00'
        valid_until: '2026-12-31T23:59:59+08:00'
        items:
          - user_id: 11111111-1111-1111-1111-111111111111
            points: 500
            memo: 战略客户 A
          - user_id: 22222222-2222-2222-2222-222222222222
            points: 500
            memo: 战略客户 B

    IssueLongTermBatchResponse:
      type: object
      required: [batch_id, status, accepted_count, total_points]
      properties:
        batch_id: { type: string, format: uuid }
        status:
          type: string
          enum: [completed, partial_succeeded]
        accepted_count: { type: integer, minimum: 0 }
        rejected_count: { type: integer, minimum: 0 }
        total_points: { type: integer, minimum: 0 }
        rejections:
          type: array
          items:
            type: object
            properties:
              user_id: { type: string, format: uuid }
              error_code: { type: string }
              message: { type: string }

    # -------------------- Operator — Ledger 审计 --------------------
    LedgerEntry:
      type: object
      required:
        - id
        - tenant_id
        - user_id
        - bucket
        - change_type
        - amount
        - balance_after
        - created_at
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        bucket:
          type: string
          enum: [activity, recharge, longterm]
        change_type:
          type: string
          enum: [grant, deduct, freeze, unfreeze, expire, refund, adjust]
        amount: { type: integer, description: '正数为入账，负数为出账' }
        balance_after: { type: integer, minimum: 0 }
        ref_type:
          type: string
          nullable: true
          enum: [task_run, batch, manual_adjust, payment_order, refund, expire_job, points_card_redeem, null]
        ref_id: { type: string, format: uuid, nullable: true }
        memo: { type: string, nullable: true }
        operator_id: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time }

    LedgerListResponse:
      type: object
      required: [items, pagination]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/LedgerEntry' }
        pagination: { $ref: '#/components/schemas/PaginationMeta' }

    # -------------------- Operator — 冻结排查 --------------------
    FreezeRecord:
      type: object
      required:
        - id
        - tenant_id
        - user_id
        - frozen_amount
        - status
        - created_at
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        bucket:
          type: string
          enum: [activity, recharge, longterm]
        frozen_amount: { type: integer, minimum: 1 }
        released_amount: { type: integer, minimum: 0 }
        status:
          type: string
          enum: [active, released, partially_released, expired]
        ref_task_id: { type: string, format: uuid, nullable: true }
        ref_run_id: { type: string, format: uuid, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        released_at: { type: string, format: date-time, nullable: true }
        aged_seconds:
          type: integer
          minimum: 0
          description: 已存活秒数（now - created_at）

    FreezeListResponse:
      type: object
      required: [items, pagination]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/FreezeRecord' }
        pagination: { $ref: '#/components/schemas/PaginationMeta' }

    # -------------------- Operator — 手工调账 --------------------
    ManualAdjustRequest:
      type: object
      required: [user_id, bucket, amount, reason_code, memo]
      properties:
        user_id: { type: string, format: uuid }
        bucket:
          type: string
          enum: [activity, recharge, longterm]
        amount:
          type: integer
          description: '正数为入账（grant），负数为出账（deduct）；长效扣减按 FIFO'
        reason_code:
          type: string
          enum: [compensation, error_correction, contract_settlement, fraud_clawback, other]
        memo: { type: string, minLength: 4, maxLength: 1000 }
        ticket_url: { type: string, format: uri, nullable: true }
      example:
        user_id: 11111111-1111-1111-1111-111111111111
        bucket: longterm
        amount: -100
        reason_code: error_correction
        memo: 修正 OPS-2026-0513 误发放
        ticket_url: https://oncall.example.com/tickets/OPS-2026-0513

    ManualAdjustResponse:
      type: object
      required: [ledger_entry_id, balance_after]
      properties:
        ledger_entry_id: { type: string, format: uuid }
        balance_after: { type: integer, minimum: 0 }
        audit_log_id: { type: string, format: uuid }

    # -------------------- Operator — 对账触发 --------------------
    ReconcileRequest:
      type: object
      required: [scope, window_from, window_to]
      properties:
        scope:
          type: string
          enum: [points_balance, ledger_payment, points_card, channel_bill]
          description: |
            channel_bill（PE2-4）：渠道账单对账——微信 trade bill + PayPal
            transaction listing 对照本地 payment_orders/payment_refunds。
            findings 一律 tier2 record-only（不自动修复、不冻结；渠道侧 vs
            本地的差异须经人工裁决）。渠道凭据未接线时 503 fail-closed。
        window_from: { type: string, format: date-time }
        window_to: { type: string, format: date-time }
        dry_run:
          type: boolean
          default: true
          description: 'false 需 X-MFA-Token；将真正落库修复'
        memo: { type: string, maxLength: 500 }

    ReconcileResponse:
      type: object
      required: [reconcile_id, status]
      properties:
        reconcile_id: { type: string, format: uuid }
        status:
          type: string
          enum: [matched, drift_detected, repaired, running]
        diff_count: { type: integer, minimum: 0 }
        repaired_count: { type: integer, minimum: 0 }
        report_url:
          type: string
          format: uri
          nullable: true
          description: '完整对账明细的限时签名 URL（TTL ≤ 1h）'

    # -------------------- Operator — 积分卡模板 --------------------
    PointsCardTemplateCreateRequest:
      type: object
      required: [name, face_value, currency, redeem_expires_in_days]
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
        description: { type: string, maxLength: 1000 }
        face_value:
          type: integer
          minimum: 1
          description: '兑换后入账的活动积分数'
        currency:
          type: string
          enum: [activity_points]
        valid_from: { type: string, format: date-time, nullable: true }
        valid_until: { type: string, format: date-time, nullable: true }
        redeem_expires_in_days:
          type: integer
          minimum: 1
          maximum: 3650
        max_redeem_per_user:
          type: integer
          minimum: 1
          nullable: true
        card_code_prefix:
          type: string
          pattern: '^[A-Z0-9]{2,8}$'
          nullable: true
      example:
        name: 春季促销卡 100 点
        description: 2026 春季拉新活动赠送卡
        face_value: 100
        currency: activity_points
        valid_from: '2026-05-01T00:00:00+08:00'
        valid_until: '2026-07-31T23:59:59+08:00'
        redeem_expires_in_days: 90
        max_redeem_per_user: 1
        card_code_prefix: SPRG

    PointsCardTemplate:
      type: object
      required: [id, tenant_id, name, face_value, currency, redeem_expires_in_days, status, created_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        face_value: { type: integer, minimum: 1 }
        currency: { type: string }
        valid_from: { type: string, format: date-time, nullable: true }
        valid_until: { type: string, format: date-time, nullable: true }
        redeem_expires_in_days: { type: integer, minimum: 1 }
        max_redeem_per_user: { type: integer, nullable: true }
        card_code_prefix: { type: string, nullable: true }
        status:
          type: string
          enum: [active, archived]
        created_at: { type: string, format: date-time }

    # -------------------- Operator — 积分卡实体 --------------------
    PointsCardSummary:
      type: object
      required: [id, template_id, batch_id, status, created_at]
      properties:
        id: { type: string, format: uuid }
        template_id: { type: string, format: uuid }
        batch_id: { type: string, format: uuid }
        card_code_masked:
          type: string
          description: '前缀 + 末4位，例 GIFT****1234；明文卡号不通过此接口出库'
        status:
          type: string
          enum: [unused, redeemed, voided, expired]
        redeemed_by: { type: string, format: uuid, nullable: true }
        redeemed_at: { type: string, format: date-time, nullable: true }
        voided_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    PointsCardListResponse:
      type: object
      required: [items, pagination]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PointsCardSummary' }
        pagination: { $ref: '#/components/schemas/PaginationMeta' }

    PointsCard:
      allOf:
        - $ref: '#/components/schemas/PointsCardSummary'
        - type: object
          properties:
            void_reason: { type: string, nullable: true }
            void_operator_id: { type: string, format: uuid, nullable: true }

    PointsCardIssueBatchRequest:
      type: object
      required: [template_id, quantity, reason_code]
      properties:
        template_id: { type: string, format: uuid }
        quantity:
          type: integer
          minimum: 1
          maximum: 1000
        reason_code:
          type: string
          enum: [marketing_grant, channel_distribution, manual_issue, other]
        memo: { type: string, maxLength: 1000 }
        delivery_email:
          type: string
          format: email
          nullable: true
          description: '签名 URL 的额外通知邮箱（可选；同租户范围）'
      example:
        template_id: 33333333-3333-3333-3333-333333333333
        quantity: 1000
        reason_code: marketing_grant
        memo: Q2 春季拉新批量发行
        delivery_email: ops-cards@acme.example

    PointsCardIssueBatchResponse:
      type: object
      required: [batch_id, task_id, status_url]
      properties:
        batch_id: { type: string, format: uuid }
        task_id: { type: string, format: uuid }
        status_url: { type: string, format: uri-reference }
        download_url:
          type: string
          format: uri
          nullable: true
          description: '完成后回填；明文卡号 ZIP 限时签名 URL（TTL ≤ 1h）'

    PointsCardVoidRequest:
      type: object
      required: [reason]
      properties:
        reason: { type: string, minLength: 4, maxLength: 1000 }
        notify_holder:
          type: boolean
          default: false
          description: '是否通过站内信通知持有人；仅当卡已绑定持有人时生效'

    # -------------------- Operator — 支付订单 --------------------
    PaymentOrderSummary:
      type: object
      required:
        - id
        - tenant_id
        - user_id
        - business_type
        - status
        - amount_cents
        - currency
        - created_at
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        business_type:
          type: string
          enum: [recharge, points_card_purchase]
        channel: { type: string, description: 'wechat / alipay / stripe / ...' }
        status:
          type: string
          enum: [pending, paid, failed, refunding, refunded, canceled]
        amount_cents: { type: integer, minimum: 0 }
        refunded_cents: { type: integer, minimum: 0 }
        currency: { type: string, example: CNY }
        created_at: { type: string, format: date-time }
        paid_at: { type: string, format: date-time, nullable: true }

    PaymentOrderListResponse:
      type: object
      required: [items, pagination]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PaymentOrderSummary' }
        pagination: { $ref: '#/components/schemas/PaginationMeta' }

    PaymentOrderDetail:
      allOf:
        - $ref: '#/components/schemas/PaymentOrderSummary'
        - type: object
          properties:
            channel_order_id: { type: string, nullable: true }
            state_transitions:
              type: array
              items:
                type: object
                required: [from_status, to_status, occurred_at]
                properties:
                  from_status: { type: string, nullable: true }
                  to_status: { type: string }
                  occurred_at: { type: string, format: date-time }
                  reason: { type: string, nullable: true }
                  operator_id: { type: string, format: uuid, nullable: true }
            related_ledger_entry_ids:
              type: array
              items: { type: string, format: uuid }
            refunds:
              type: array
              items:
                type: object
                required: [id, amount_cents, status, created_at]
                properties:
                  id: { type: string, format: uuid }
                  amount_cents: { type: integer, minimum: 0 }
                  status:
                    type: string
                    enum: [pending, succeeded, failed]
                  reason_code: { type: string, nullable: true }
                  created_at: { type: string, format: date-time }
                  completed_at: { type: string, format: date-time, nullable: true }

    PaymentRefundRequest:
      type: object
      required: [refund_amount_cents, reason_code]
      properties:
        refund_amount_cents:
          type: integer
          minimum: 1
          description: '不可超过 amount_cents - already_refunded'
        reason_code:
          type: string
          enum: [user_request, fraud, duplicate_charge, service_failure, other]
        memo: { type: string, maxLength: 1000 }
        recall_points:
          type: boolean
          default: true
          description: '是否级联反向扣回已发放的活动积分（默认 true）'
      example:
        refund_amount_cents: 9900
        reason_code: user_request
        memo: 客户主动取消，订单未发货
        recall_points: true

    PaymentRefundResponse:
      type: object
      required: [refund_id, task_id, status_url]
      properties:
        refund_id: { type: string, format: uuid }
        task_id: { type: string, format: uuid }
        status_url: { type: string, format: uri-reference }

    # -------------------- Operator — 租户 --------------------
    Tenant:
      type: object
      required: [id, slug, name, status, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        slug:
          type: string
          pattern: '^[a-z0-9][a-z0-9-]{2,62}$'
          description: 全局唯一短码；冲突返回 TENANT_SLUG_DUPLICATED
        name: { type: string, minLength: 2, maxLength: 80 }
        status:
          type: string
          enum: [active, suspended, deleted]
        keycloak_realm: { type: string, nullable: true }
        owner_user_id: { type: string, format: uuid, nullable: true }
        metadata_json:
          type: object
          additionalProperties: true
          description: '约定子键：quota / contact / billing / region；具体 schema 待 L3-D 配额模型对齐'
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TenantCreateRequest:
      type: object
      required: [slug, name, owner]
      properties:
        slug: { type: string, pattern: '^[a-z0-9][a-z0-9-]{2,62}$' }
        name: { type: string, minLength: 2, maxLength: 80 }
        owner:
          type: object
          required: [email]
          properties:
            email: { type: string, format: email }
            display_name: { type: string, maxLength: 80 }
            send_invite: { type: boolean, default: true }
        initial_quota:
          type: object
          additionalProperties: true
          description: '初始配额；与 metadata_json.quota 同 schema'
        contact:
          type: object
          properties:
            primary_email: { type: string, format: email }
            phone: { type: string, nullable: true }
        memo: { type: string, maxLength: 1000 }
      example:
        slug: acme-corp
        name: Acme 集团
        owner:
          email: admin@acme.example
          display_name: Alice Chen
          send_invite: true
        initial_quota:
          monthly_active_points: 100000
          max_users: 500
        contact:
          primary_email: ops@acme.example
          phone: '+86-13800138000'
        memo: 大客户合同 CT-2026-0042

    TenantCreateAcceptedResponse:
      type: object
      required: [tenant, task_id, status_url]
      properties:
        tenant: { $ref: '#/components/schemas/Tenant' }
        task_id: { type: string, format: uuid }
        status_url: { type: string, format: uri-reference }

    TenantUpdateRequest:
      type: object
      description: 'JSON Merge Patch；slug / status 不可改'
      properties:
        name: { type: string, minLength: 2, maxLength: 80 }
        owner_user_id: { type: string, format: uuid, nullable: true }
        metadata_json:
          type: object
          additionalProperties: true

    TenantSuspendRequest:
      type: object
      required: [reason_code]
      properties:
        reason_code:
          type: string
          enum: [overdue, fraud, manual_pause, ops_violation, other]
        memo: { type: string, maxLength: 1000 }
        ticket_url: { type: string, format: uri, nullable: true }
      example:
        reason_code: fraud
        memo: 触发风控规则 RULE_PAY_FRAUD_2026
        ticket_url: https://oncall.example.com/tickets/OPS-2026-0514

    TenantQuotaResetRequest:
      type: object
      description: '配额字段集合待 L3-D 模型对齐后冻结；当前以 additionalProperties 兜底'
      additionalProperties: true

    TenantDetail:
      allOf:
        - $ref: '#/components/schemas/Tenant'
        - type: object
          properties:
            user_count_active: { type: integer, minimum: 0 }
            role_count_custom: { type: integer, minimum: 0 }
            quota_usage:
              type: object
              additionalProperties: true

    TenantListResponse:
      type: object
      required: [items, pagination]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Tenant' }
        pagination: { $ref: '#/components/schemas/PaginationMeta' }

    # -------------------- Operator — 用户 --------------------
    User:
      type: object
      required: [id, tenant_id, email, status, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        keycloak_user_id: { type: string, nullable: true }
        email: { type: string, format: email }
        display_name: { type: string, maxLength: 80, nullable: true }
        status:
          type: string
          enum: [active, pending, disabled, deleted]
        mfa_enabled: { type: boolean, default: false }
        last_login_at: { type: string, format: date-time, nullable: true }
        metadata_json:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    UserInviteRequest:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
        display_name: { type: string, maxLength: 80 }
        role_codes:
          type: array
          items: { type: string }
          description: '初始角色（系统 / 自定义 code）；不传则仅创建无角色用户'
        send_invite_email: { type: boolean, default: true }
        memo: { type: string, maxLength: 500 }
      example:
        email: alice@acme.example
        display_name: Alice Chen
        role_codes: [builder-admin, ops-readonly]
        send_invite_email: true
        memo: 新入职 Builder 团队负责人

    UserUpdateRequest:
      type: object
      description: 'JSON Merge Patch；email / status / tenant_id 不可改'
      properties:
        display_name: { type: string, maxLength: 80, nullable: true }
        metadata_json:
          type: object
          additionalProperties: true

    UserDisableRequest:
      type: object
      properties:
        reason_code:
          type: string
          enum: [resign, security_incident, ops_violation, manual_pause, other]
        memo: { type: string, maxLength: 1000 }

    UserDeleteRequest:
      type: object
      properties:
        reason_code:
          type: string
          enum: [resign, gdpr_erasure, fraud, other]
        memo: { type: string, maxLength: 1000 }
        ticket_url: { type: string, format: uri, nullable: true }

    UserMfaResetResponse:
      type: object
      required: [user_id, mfa_enabled]
      properties:
        user_id: { type: string, format: uuid }
        mfa_enabled: { type: boolean }
        required_actions:
          type: array
          items: { type: string }
          description: 'Keycloak required_actions 列表，预期含 CONFIGURE_TOTP'

    UserForceLogoutResponse:
      type: object
      required: [user_id, revoked_session_count]
      properties:
        user_id: { type: string, format: uuid }
        revoked_session_count: { type: integer, minimum: 0 }
        broadcasted_at: { type: string, format: date-time }

    UserDetail:
      allOf:
        - $ref: '#/components/schemas/User'
        - type: object
          properties:
            roles:
              type: array
              items: { $ref: '#/components/schemas/Role' }
            quota_usage:
              type: object
              additionalProperties: true

    UserListResponse:
      type: object
      required: [items, pagination]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/User' }
        pagination: { $ref: '#/components/schemas/PaginationMeta' }

    # -------------------- Operator — 角色 --------------------
    Role:
      type: object
      required: [id, code, name, is_system, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id:
          type: string
          format: uuid
          nullable: true
          description: '系统角色为 null；自定义角色绑定到具体租户'
        code:
          type: string
          pattern: '^[a-z][a-z0-9_]{2,63}$'
          description: '同租户内唯一；冲突返回 ROLE_CODE_DUPLICATED'
        name: { type: string, minLength: 2, maxLength: 80 }
        description: { type: string, maxLength: 500, nullable: true }
        is_system:
          type: boolean
          description: 'true 表示系统内置；不可修改 / 删除'
        permission_codes:
          type: array
          items: { type: string }
          description: '权限点全集，详见 api_design.md §5.2'
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    RoleCreateRequest:
      type: object
      required: [code, name, permission_codes]
      properties:
        code: { type: string, pattern: '^[a-z][a-z0-9_]{2,63}$' }
        name: { type: string, minLength: 2, maxLength: 80 }
        description: { type: string, maxLength: 500 }
        permission_codes:
          type: array
          items: { type: string }
          minItems: 1
      example:
        code: builder_readonly
        name: Builder 只读
        description: Builder 域所有资源的只读权限
        permission_codes: [sop:read, agent:read, manifest:read, knowledge:read]

    RoleUpdateRequest:
      type: object
      description: 'JSON Merge Patch；code / is_system / tenant_id 不可改；permission_codes 给值则全量替换'
      properties:
        name: { type: string, minLength: 2, maxLength: 80 }
        description: { type: string, maxLength: 500, nullable: true }
        permission_codes:
          type: array
          items: { type: string }

    RoleListResponse:
      type: object
      required: [items, pagination]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Role' }
        pagination: { $ref: '#/components/schemas/PaginationMeta' }

    # -------------------- Operator — 用户—角色绑定 --------------------
    RoleAssignRequest:
      type: object
      properties:
        memo: { type: string, maxLength: 500 }
      example:
        memo: 2026 Q2 项目期临时授权

    UserRoleAssignment:
      type: object
      required: [user_id, role_id, assigned_at]
      properties:
        user_id: { type: string, format: uuid }
        role_id: { type: string, format: uuid }
        role_code: { type: string }
        assigned_by: { type: string, format: uuid, nullable: true }
        assigned_at: { type: string, format: date-time }
        memo: { type: string, nullable: true }

    # -------------------- SaaS — 应用元信息（Manifest Handshake / Runtime） --------------------
    HandshakeWarning:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          description: |
            权威码表维护在 L2-D §8.3 兼容性判定矩阵；常见取值：
            SDK_MAJOR_BEHIND / SDK_MINOR_BEHIND / MANIFEST_DEPRECATED / MANIFEST_ARCHIVED /
            EVENT_SCHEMA_BREAK / GRAYSCALE_NOT_HIT
        message: { type: string }
        details:
          type: object
          additionalProperties: true
          description: |
            可选附加上下文；当 code='GRAYSCALE_NOT_HIT' 时含 target_grayscale_cohort 等字段。

    ManifestHandshakeResponse:
      type: object
      required:
        - status
        - app_id
        - client_manifest_version
        - server_manifest_version
        - server_latest_manifest_version
        - server_runtime_event_schema_version
        - warnings
        - incompatible_reasons
        - session_token
        - session_token_ttl_seconds
        - checked_at
      properties:
        status:
          type: string
          enum: [compatible, compatible_with_warning, incompatible]
        app_id: { type: string }
        client_manifest_version: { type: string }
        server_manifest_version: { type: string }
        server_latest_manifest_version: { type: string }
        server_runtime_event_schema_version: { type: integer, minimum: 1 }
        recommended_sdk_version:
          type: string
          nullable: true
          description: 服务端为该 app_id 当前 published Manifest 配置的 sdk_compat_range 上界；无明确推荐时省略
        warnings:
          type: array
          items: { $ref: '#/components/schemas/HandshakeWarning' }
        incompatible_reasons:
          type: array
          items: { $ref: '#/components/schemas/HandshakeWarning' }
          description: status='incompatible' 时非空，详见 L2-D §8.3
        upgrade_guide_url:
          type: string
          format: uri
          nullable: true
        session_token:
          type: string
          description: 一次性会话令牌；后续请求由网关解码后与请求头交叉校验
        session_token_ttl_seconds:
          type: integer
          minimum: 1
          description: 默认 3600
        checked_at:
          type: string
          format: date-time

    ManifestDeprecation:
      type: object
      required: [deprecated_at, archive_at, latest_published_version]
      properties:
        deprecated_at: { type: string, format: date-time }
        archive_at:
          type: string
          format: date-time
          description: 90 天保留窗口结束时间；超过后视版本为 archived
        latest_published_version: { type: string }
        upgrade_guide_url:
          type: string
          format: uri
          nullable: true

    SaasManifestCatalogBindingSummary:
      type: object
      required: [feature_key, workflow_key, estimated_points]
      properties:
        feature_key: { type: string }
        workflow_key: { type: string }
        estimated_points:
          type: integer
          minimum: 0
          description: 自 binding.billing_rules.estimated_points 派生；缺省 0（免计费）

    SaasManifestCatalogItem:
      type: object
      required:
        - app_id
        - project_id
        - version
        - display_name
        - supported_channels
        - binding_summaries
      properties:
        app_id: { type: string }
        project_id: { type: string, format: uuid }
        version: { type: integer, minimum: 1 }
        display_name:
          type: string
          description: theme_config.display_name 或回退 app_id
        supported_channels:
          type: array
          items: { type: string }
        binding_summaries:
          type: array
          items: { $ref: '#/components/schemas/SaasManifestCatalogBindingSummary' }

    SaasManifestCatalogPage:
      type: object
      required: [items, total, limit, offset]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/SaasManifestCatalogItem' }
        total: { type: integer, minimum: 0 }
        limit: { type: integer }
        offset: { type: integer }

    SaasManifestRuntime:
      type: object
      required:
        - app_id
        - manifest_version
        - name
        - channel
        - frontend_features
        - feature_flags_resolved
        - channel_config
        - event_subscription_policy
        - manifest_etag
        - fetched_at
      properties:
        app_id: { type: string }
        manifest_version: { type: string }
        name: { type: string }
        channel:
          type: string
          enum: [web, wechat_mini, h5]
        frontend_features:
          type: array
          items:
            type: object
            required: [feature_key, render_hint, enabled]
            properties:
              feature_key: { type: string }
              render_hint: { type: string }
              enabled: { type: boolean }
        feature_flags_resolved:
          type: object
          additionalProperties: true
          description: public_manifest.feature_flags 与用户角色 / 灰度命中合并后的最终值
        channel_config:
          type: object
          additionalProperties: true
          description: |
            含 event_transport / login_methods / upload_endpoint 等；
            敏感字段（如商户号、apiv3_key）从不在本域返回
        payment_methods_public:
          type: array
          nullable: true
          items:
            type: object
            required: [type]
            properties:
              type: { type: string }
              min_amount_cents: { type: integer, minimum: 0 }
              display_name: { type: string }
        event_subscription_policy:
          type: object
          properties:
            allowed_types:
              type: array
              items: { type: string }
        grayscale:
          type: object
          nullable: true
          properties:
            hit: { type: boolean }
            cohort: { type: string, nullable: true }
            rollout_percentage:
              type: integer
              minimum: 0
              maximum: 100
        branding:
          type: object
          nullable: true
          properties:
            logo_url: { type: string, format: uri }
            primary_color: { type: string }
        deprecation:
          allOf:
            - $ref: '#/components/schemas/ManifestDeprecation'
          nullable: true
          description: 命中 deprecated 版本时非空；命中 published 时为 null
        min_client_version:
          type: string
          nullable: true
          description: 与 public.version.get 的强制升级判定对齐
        manifest_etag:
          type: string
          description: Weak ETag；本期不强制 304，但已先行下发避免破坏性变更
        fetched_at:
          type: string
          format: date-time

    # ============================================================
    # SaaS User — 工作台域 schemas（详见 endpoints/saas-workbench.md）
    # ============================================================
    WorkflowTaskStatus:
      type: string
      enum:
        - created
        - running
        - waiting_human
        - paused
        - succeeded
        - failed
        - cancelling
        - cancelled
        - timeout
      description: |
        工作台任务状态机；created/running/waiting_human/cancelling 为活跃态，
        paused 为运营冻结态，succeeded/failed/cancelled/timeout 为终态。

    WorkflowTaskChannel:
      type: string
      enum: [web, wechat_mini, android, ios, api]
      description: 触发渠道；落 workflow_tasks.channel，用于多渠道分摊统计

    WorkflowTaskSubmitRequest:
      type: object
      required: [app_id, manifest_version, inputs]
      properties:
        app_id:
          type: string
          description: 目标 Manifest 业务标识；与 X-AgentHub-App-Id 一致校验，否则 403 SESSION_MISMATCH
        manifest_version:
          type: string
          description: |
            必须等于 published 当前激活版本或仍在 90 天保留窗口的 deprecated；
            否则 409 MANIFEST_VERSION_MISMATCH（由 saas-manifest 域统一登记）
        inputs:
          type: object
          additionalProperties: true
          description: |
            必须满足 manifest.input_schema 引用的 JSON Schema；
            违反返 422 INPUT_SCHEMA_VIOLATION（错误体含 violations[].path / message）
        channel:
          $ref: '#/components/schemas/WorkflowTaskChannel'
        client_trace_id:
          type: string
          maxLength: 64
          nullable: true
          description: 客户端可观测维度；若为空由服务端生成并回吐 X-Request-Id
        budget_estimate_override:
          type: integer
          minimum: 1
          nullable: true
          description: |
            可选；客户端心智价上限。若提供则 max(estimated_points_from_manifest, override) 作为冻结额；
            超出 manifest.budget_ceiling 时返 422 BUDGET_OVERRIDE_EXCEEDED
      example:
        app_id: refund_assistant
        manifest_version: '5'
        inputs:
          order_id: ORD-2026-05-14-0001
          customer_reason: 商品损坏
        channel: web
        client_trace_id: trace-2026-05-14-0001

    WorkflowTaskSubmitResponse:
      type: object
      required: [task_id, status, version, estimated_points, freeze_record_id, submitted_at]
      properties:
        task_id:
          type: string
          description: workflow_tasks.id（前缀 wt_）
        status:
          $ref: '#/components/schemas/WorkflowTaskStatus'
        version:
          type: integer
          minimum: 1
          description: 乐观锁版本号；首次提交固定为 1
        estimated_points:
          type: integer
          minimum: 1
          description: 实际冻结积分数（取自 manifest.estimated_points 或 override 后的值）
        freeze_record_id:
          type: string
          format: uuid
          description: freeze_records.id；后续 unfreeze / settle 钩子会回写此行
        submitted_at:
          type: string
          format: date-time
        hard_deadline_at:
          type: string
          format: date-time
          description: |
            兜底硬超时；默认 submitted_at + 72h，触达后服务端按 timeout 终态收尾并回执补偿

    WorkflowTaskHumanResumeRequest:
      type: object
      required: [operation_id, expected_version, resume_payload]
      properties:
        operation_id:
          type: string
          format: uuid
          description: 客户端持久化操作 ID；与 Idempotency-Key 双锁，落 workflow_task_operations.operation_id UNIQUE
        expected_version:
          type: integer
          minimum: 1
          description: 客户端持有的 workflow_tasks.version；CAS 不匹配返 409 OPTIMISTIC_LOCK_CONFLICT
        resume_payload:
          type: object
          additionalProperties: true
          description: |
            必须满足当前 pause_point.prompt_schema_ref 引用的 JSON Schema；
            违反返 422 RESUME_PAYLOAD_INVALID（错误体含 schema_ref / violations[]）
        operator_note:
          type: string
          maxLength: 500
          nullable: true
          description: 仅留痕用，不参与状态机判定
      example:
        operation_id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
        expected_version: 3
        resume_payload:
          decision: approve
          comments: 已核对图片证据
        operator_note: 与坐席电话确认通过

    WorkflowTaskHumanResumeResponse:
      type: object
      required: [task_id, status, version, accepted_at]
      properties:
        task_id: { type: string }
        status:
          $ref: '#/components/schemas/WorkflowTaskStatus'
          description: 受理后通常为 running；后续异步推进由 SSE 事件流通告
        version:
          type: integer
          minimum: 2
          description: CAS 成功后服务端 +1，客户端用此值刷新本地缓存
        accepted_at:
          type: string
          format: date-time

    WorkflowTaskCancelRequest:
      type: object
      required: [operation_id, reason_code]
      properties:
        operation_id:
          type: string
          format: uuid
          description: 与 Idempotency-Key 双锁
        reason_code:
          type: string
          enum: [user_abort, manual_review_failed, timeout_local, other]
          description: 取消原因枚举；进入 ledger 备注 + 客服工单口径
        reason_detail:
          type: string
          maxLength: 1000
          nullable: true
        expected_version:
          type: integer
          minimum: 1
          nullable: true
          description: 可选 CAS；若提供且不匹配 → 409 OPTIMISTIC_LOCK_CONFLICT
      example:
        operation_id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
        reason_code: user_abort
        reason_detail: 用户主动取消
        expected_version: 4

    WorkflowTaskCancelResponse:
      type: object
      required: [task_id, status, version, accepted_at]
      properties:
        task_id: { type: string }
        status:
          $ref: '#/components/schemas/WorkflowTaskStatus'
          description: 受理后置 cancelling；终态 cancelled 由补偿钩子完成后异步通告
        version:
          type: integer
          minimum: 2
        accepted_at:
          type: string
          format: date-time
        compensation_hooks:
          type: array
          description: 已登记待异步执行的补偿钩子序列；客户端仅作展示
          items:
            type: string
            enum: [unfreeze, refund_partial, notify]

    WorkflowTaskPausePoint:
      type: object
      required: [pause_point_id, node_key, prompt_schema_ref, opened_at]
      description: 当 status='waiting_human' 时随 include=pause_point 返回
      properties:
        pause_point_id: { type: string, format: uuid }
        node_key: { type: string, description: 触发暂停的 LangGraph 节点 key }
        prompt_schema_ref:
          type: string
          description: |
            JSON Schema 引用路径，例如 'manifest://app/agent_a/v1.2.0#/pauses/review'；
            human_resume.resume_payload 必须按此 Schema 校验
        prompt_text:
          type: string
          nullable: true
          description: 给 C 端的提示文案；可由 manifest 国际化字段渲染
        opened_at: { type: string, format: date-time }
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: 暂停点级别过期时间；为空时回退至 task 级 hard_deadline_at

    WorkflowTask:
      type: object
      required:
        - task_id
        - app_id
        - manifest_version
        - status
        - version
        - estimated_points
        - submitted_at
      properties:
        task_id: { type: string }
        app_id: { type: string }
        manifest_version: { type: string }
        channel:
          $ref: '#/components/schemas/WorkflowTaskChannel'
        status:
          $ref: '#/components/schemas/WorkflowTaskStatus'
        version:
          type: integer
          minimum: 1
        estimated_points:
          type: integer
          minimum: 1
        actual_points:
          type: integer
          minimum: 0
          nullable: true
          description: 终态时由 settle 钩子回写；非终态返回 null
        freeze_record_id:
          type: string
          format: uuid
        submitted_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time, nullable: true }
        finished_at: { type: string, format: date-time, nullable: true }
        hard_deadline_at: { type: string, format: date-time }
        client_trace_id:
          type: string
          nullable: true
        summary:
          type: object
          nullable: true
          description: include=summary 命中时返回；含进度 / 节点数 / 当前节点 key
          properties:
            progress: { type: number, minimum: 0, maximum: 1 }
            current_node_key: { type: string, nullable: true }
            total_nodes: { type: integer, minimum: 0 }
            finished_nodes: { type: integer, minimum: 0 }
        pause_point:
          allOf:
            - $ref: '#/components/schemas/WorkflowTaskPausePoint'
          nullable: true
          description: include=pause_point 命中且 status='waiting_human' 时返回
        item_tasks_count:
          type: integer
          minimum: 0
          nullable: true
          description: include=item_tasks_count 命中时返回（散件子任务总数）
        last_event:
          type: object
          nullable: true
          description: include=last_event 命中时返回最新一条 RuntimeEvent 摘要
          properties:
            event_id: { type: string }
            event_kind: { type: string }
            occurred_at: { type: string, format: date-time }

    WorkflowTaskListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/WorkflowTask' }
        next_cursor:
          type: string
          nullable: true
        has_more: { type: boolean }

    WorkflowTaskEvent:
      type: object
      required: [event_id, event_kind, schema_version, occurred_at, payload]
      properties:
        event_id:
          type: string
          description: 全局单调递增；客户端可作 since / Last-Event-ID 续传锚点
        event_kind:
          type: string
          description: |
            层级命名；常见前缀 workflow_task.* / node.* / tool.* / llm.* / human.* / points.*；
            完整目录见 L2-D §RuntimeEvent 总册
        schema_version:
          type: integer
          minimum: 1
          description: RuntimeEvent Schema 版本，与 X-AgentHub-Runtime-Event-Schema 协商一致
        occurred_at: { type: string, format: date-time }
        sequence:
          type: integer
          minimum: 0
          description: 任务内事件单调序号；和 event_id 各自独立，便于 UI 排序
        payload:
          type: object
          additionalProperties: true
          description: 事件载荷；按 event_kind 分别引用 RuntimeEvent 总册中的 schema 子片

    WorkflowTaskEventListResponse:
      type: object
      required: [data]
      description: 轮询模式下的响应；SSE 模式下不使用此 schema
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/WorkflowTaskEvent' }
        next_since:
          type: string
          nullable: true
          description: 下一轮拉取应传入的 since 锚点；终态后为 null
        terminal:
          type: boolean
          description: 任务已进入终态；客户端可停止轮询并展示最终结果

    # -------------------- Public / Internal / Webhook --------------------
    HealthCheckResponse:
      type: object
      required: [status, service]
      properties:
        status:
          type: string
          enum: [ok]
          description: 进程级存活恒为 ok；不存活时由 K8s 直接转 502
        service:
          type: string
          example: agenthub-api
          description: 服务标识；LB 多服务共域名时用于区分
        uptime_seconds:
          type: integer
          minimum: 0
          description: 进程启动至今秒数

    ReadinessCheckResponse:
      type: object
      required: [status, checked_at]
      properties:
        status:
          type: string
          enum: [ready, not_ready]
        checked_at:
          type: string
          format: date-time
        dependencies:
          type: array
          description: 仅当 verbose=true 且授权放行时返回；生产默认裁剪
          items:
            type: object
            required: [name, status]
            properties:
              name:
                type: string
                enum: [postgres, valkey, keycloak, object_storage]
              status:
                type: string
                enum: [ok, degraded, down]
              latency_ms: { type: integer, minimum: 0 }

    VersionInfoResponse:
      type: object
      required: [api_version, build_version, git_commit, built_at]
      properties:
        api_version: { type: string, example: v1 }
        build_version:
          type: string
          description: 与 CI tag 一致
          example: 1.4.7
        git_commit:
          type: string
          description: 短 hash（7 字符）
          example: a1b2c3d
        built_at:
          type: string
          format: date-time
        min_client_version:
          type: object
          description: 用于强制升级提示；前端低于该版本应阻断业务并引导升级
          additionalProperties: { type: string }
          example:
            web: 1.4.0
            miniapp: 1.3.5

    PublicManifestRuntime:
      type: object
      required: [app_id, manifest_version, name]
      description: 匿名场景下返回的 Manifest 最小切片；剥离 payment_config / login_config / internal_metadata 等敏感字段
      properties:
        app_id: { type: string }
        manifest_version: { type: string }
        name: { type: string }
        channel:
          type: string
          enum: [web, miniapp, h5]
        frontend_features:
          type: array
          items:
            type: object
            required: [feature_key]
            properties:
              feature_key: { type: string }
              render_hint: { type: string }
        branding:
          type: object
          additionalProperties: true
          properties:
            logo_url: { type: string, format: uri }
            primary_color: { type: string }
        min_client_version: { type: string }

    InternalTaskCallbackRequest:
      type: object
      required: [event_seq, phase, status, occurred_at]
      properties:
        event_seq:
          type: integer
          minimum: 0
          description: 任务内单调递增；调用方持久化，崩溃恢复后从上次未确认 seq 重发
        phase:
          type: string
          description: 与 L2-A RuntimeEvent 类型对齐；新增 phase 必须先在专项文档登记
          example: node.finished
        node_id: { type: string, nullable: true }
        status:
          type: string
          enum: [pending, running, succeeded, failed, cancelled, timeout]
        outputs_ref:
          type: string
          nullable: true
          description: 对象存储 URL；服务端按需懒加载，不要把大体积输出塞进请求体
        metrics:
          type: object
          additionalProperties: true
          properties:
            tokens_in: { type: integer, minimum: 0 }
            tokens_out: { type: integer, minimum: 0 }
            duration_ms: { type: integer, minimum: 0 }
        occurred_at:
          type: string
          format: date-time
      example:
        event_seq: 42
        phase: node.finished
        node_id: refund_evaluation
        status: succeeded
        outputs_ref: oss://agenthub-tasks/task-abc/outputs/42.json
        metrics:
          tokens_in: 1280
          tokens_out: 420
          duration_ms: 1850
        occurred_at: '2026-05-14T10:15:32+08:00'

    InternalTaskCallbackResponse:
      type: object
      required: [task_id, accepted, applied_event_seq]
      properties:
        task_id: { type: string, format: uuid }
        accepted: { type: boolean }
        applied_event_seq: { type: integer, minimum: 0 }
        next_expected_event_seq:
          type: integer
          minimum: 0
          nullable: true
          description: 进入终态后为 null

    InternalBudgetUpdateRequest:
      type: object
      required: [task_id, node_id, call_seq, provider, model, cost_amount_cents, occurred_at]
      properties:
        task_id: { type: string, format: uuid }
        node_id: { type: string }
        call_seq:
          type: integer
          minimum: 0
          description: 节点内调用序号（与 task_id + node_id 共同形成幂等推荐键）
        provider:
          type: string
          example: openai
        model:
          type: string
          example: gpt-4o-mini
        tokens_in: { type: integer, minimum: 0 }
        tokens_out: { type: integer, minimum: 0 }
        cost_amount_cents:
          type: integer
          minimum: 0
          description: 由 Sidecar 按 provider 价格表换算后传入；服务端不二次换算
        occurred_at:
          type: string
          format: date-time
      example:
        task_id: cccccccc-cccc-cccc-cccc-cccccccccccc
        node_id: refund_evaluation
        call_seq: 3
        provider: openai
        model: gpt-4o-mini
        tokens_in: 1280
        tokens_out: 420
        cost_amount_cents: 4
        occurred_at: '2026-05-14T10:15:32+08:00'

    InternalBudgetUpdateResponse:
      type: object
      required: [accepted, circuit_state]
      properties:
        accepted: { type: boolean }
        budget_remaining_amount_cents:
          type: integer
          minimum: 0
        circuit_state:
          type: string
          enum: [closed, half_open, open]
          description: open 时调用方必须立即停止本租户下的新调用，等待 half_open 探测窗口

    OcrCallbackRequest:
      type: object
      required: [task_id, status, occurred_at]
      properties:
        task_id:
          type: string
          description: 与 documents.ocr_task_id 对齐；UNIQUE 落库 ocr_callbacks
        status:
          type: string
          enum: [succeeded, failed, partial]
        result_url:
          type: string
          format: uri
          nullable: true
          description: 引用而非内联结果；服务端拉取后写对象存储租户隔离前缀
        page_count:
          type: integer
          minimum: 0
          nullable: true
        error_code:
          type: string
          nullable: true
          description: 供应商返回的错误码（status=failed 时必填）
        occurred_at:
          type: string
          format: date-time
      example:
        task_id: ocr-2026-05-14-0001
        status: succeeded
        result_url: https://oss.example.com/ocr/task-abc.json
        page_count: 12
        occurred_at: '2026-05-14T10:18:09+08:00'

    OcrCallbackResponse:
      type: object
      required: [accepted]
      properties:
        accepted: { type: boolean }

    # =====================================================================
    # T2 (ADR-0044): grants / ledger / membership schemas
    # =====================================================================

    AdminGrantRequest:
      type: object
      required: [tenant_id, user_id, project_id, amount, operation_id, expires_at, reason]
      properties:
        tenant_id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        amount: { type: integer, minimum: 1 }
        operation_id: { type: string, minLength: 8, maxLength: 128 }
        expires_at: { type: string, format: date-time }
        reason: { type: string, minLength: 1, maxLength: 512 }

    TenantGrantRequest:
      type: object
      required: [user_id, project_id, amount, operation_id, expires_at, reason]
      properties:
        user_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        amount: { type: integer, minimum: 1 }
        operation_id: { type: string, minLength: 8, maxLength: 128 }
        expires_at: { type: string, format: date-time }
        reason: { type: string, minLength: 1, maxLength: 512 }

    GrantResult:
      type: object
      required: [batch_id, long_term_available_after, idempotent_replay, source]
      properties:
        batch_id: { type: string, format: uuid }
        long_term_available_after: { type: integer }
        idempotent_replay: { type: boolean }
        source:
          type: string
          enum: [admin_grant, tenant_grant]

    BillingLedgerEntry:
      type: object
      required:
        [id, tenant_id, user_id, project_id, account_type, transaction_type,
         amount, balance_before, balance_after, frozen_delta, operation_id,
         operation_seq, created_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        account_type: { type: string, enum: [member, long_term] }
        transaction_type: { type: string }
        amount: { type: integer }
        balance_before: { type: integer }
        balance_after: { type: integer }
        frozen_delta: { type: integer }
        operation_id: { type: string }
        operation_seq: { type: integer }
        task_id: { type: string, format: uuid, nullable: true }
        reason: { type: string, nullable: true }
        metadata_json:
          type: object
          additionalProperties: true
        created_at: { type: string, format: date-time }

    LedgerPage:
      type: object
      required: [items, total, limit, offset]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/BillingLedgerEntry' }
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }

    # ------------------------------------------------------------------
    # PP-0 (ADR-0054): C 端个人面板发布面 schemas（以实现为准：
    # billing/api/v1/endpoints/balance.py + payment.py + schemas/payment.py + schemas/points.py）
    # ------------------------------------------------------------------
    BillingBalanceEnvelope:
      type: object
      required: [code, message, data]
      description: 统一 Envelope（development_standard §2.3.1）包裹 BalanceView
      properties:
        code: { type: string, enum: [SUCCESS] }
        message: { type: string }
        data: { $ref: '#/components/schemas/BillingBalanceView' }

    BillingBalanceView:
      type: object
      required: [member_available, member_frozen, long_term_available, long_term_frozen, long_term_earliest_expiry]
      properties:
        member_available: { type: integer, minimum: 0, description: 会员月度账户可用（无订阅为 0） }
        member_frozen: { type: integer, minimum: 0 }
        long_term_available: { type: integer, minimum: 0, description: 长效账户可用（FIFO 批次聚合） }
        long_term_frozen: { type: integer, minimum: 0 }
        long_term_earliest_expiry:
          type: string
          format: date-time
          nullable: true
          description: 最早将过期的长效批次时刻（无批次为 null）

    RechargePlanItem:
      type: object
      required: [channel, currency, amount_cents, points]
      description: 单档充值方案（服务端权威价目，禁止客户端覆写；PE2-2 起按渠道×币种维度）
      properties:
        channel:
          type: string
          enum: [wechat, paypal]
          description: 支付渠道
        currency:
          type: string
          enum: [CNY, USD]
          description: ISO 4217 币种（amount_cents 的计价单位）
        amount_cents:
          type: integer
          exclusiveMinimum: 0
          description: 充值金额（currency 的最小货币单位：CNY=分，USD=美分）
        points:
          type: integer
          exclusiveMinimum: 0
          description: 到账积分数（锚定积分，不做汇率换算）

    RechargePlanListResponse:
      type: object
      required: [plans]
      description: 服务端充值档位目录
      properties:
        plans:
          type: array
          items: { $ref: '#/components/schemas/RechargePlanItem' }

    PaymentOrderCreateRequest:
      type: object
      required: [tenant_id, user_id, project_id, amount_cents, local_order_no]
      description: |
        tenant_id / user_id 为 schema 透传字段，HTTP 端点以 JWT 权威覆盖（SEM-018 R4）；
        mchid/appid 由后端 Seam ② 解析，body 不采信。
      properties:
        tenant_id: { type: string, format: uuid, description: 服务端以 JWT 覆盖 }
        user_id: { type: string, format: uuid, description: 服务端以 JWT 覆盖 }
        project_id: { type: string, format: uuid }
        order_type: { type: string, enum: [recharge, membership], default: recharge }
        amount_cents:
          type: integer
          exclusiveMinimum: 0
          description: 金额（分）；recharge 须命中服务端充值目录，否则 422
        points_amount:
          type: integer
          nullable: true
          exclusiveMinimum: 0
          description: recharge 由服务端按充值目录覆写，客户端值不采信
        plan_id: { type: string, format: uuid, nullable: true }
        plan_period_months: { type: integer, nullable: true }
        trade_type: { type: string, enum: [JSAPI, H5, NATIVE, APP, PAYPAL_WEB], default: NATIVE, description: PE2-2 增 PAYPAL_WEB（schema/CHECK 放行；端点 503 fail-closed 至 PE2-3 adapter 落地） }
        local_order_no: { type: string, minLength: 1, maxLength: 32, description: 客户端幂等订单号 }
        wechat_out_trade_no: { type: string, maxLength: 64, nullable: true, description: 缺省同 local_order_no }
        merchant_account_ref: { type: string, maxLength: 64, default: platform }
        wechat_appid: { type: string, maxLength: 64, default: "" }
        wechat_mchid: { type: string, maxLength: 64, default: "" }
        expires_at: { type: string, format: date-time, nullable: true, description: 缺省下单后 15 分钟 }
        payer_client_ip: { type: string, maxLength: 64, nullable: true }

    PaymentOrderCreateResponse:
      type: object
      required: [order_id, local_order_no, status, prepay_params]
      properties:
        order_id: { type: string, format: uuid }
        local_order_no: { type: string }
        status: { type: string, description: created / pending / paid 等订单状态机值 }
        prepay_params: { type: object, additionalProperties: true, description: 预支付参数（Native 模式含 code_url） }
        code_url: { type: string, nullable: true, description: Native 二维码 URL（微信侧 2h 有效） }
        code_url_expire_at: { type: string, format: date-time, nullable: true }
        jsapi_params:
          type: object
          nullable: true
          description: |
            JSAPI 调起参数（SM3-1，仅 trade_type=JSAPI 时非 null；Native 恒为 null）。
            供前端 WeixinJSBridge.getBrandWCPayRequest / wx.requestPayment 使用。
            包含六字段：appId / timeStamp / nonceStr / package / signType / paySign。
          additionalProperties:
            type: string

    WechatBindingRequest:
      type: object
      required: [code]
      properties:
        code: { type: string, minLength: 1, maxLength: 128, description: wx.login() 返回的临时 code }
        app_profile: { type: string, maxLength: 32, nullable: true, description: 多 appid 场景下的 profile 名（缺省=平台默认） }

    WechatBindingResponse:
      type: object
      required: [bound, openid_masked, unionid_present]
      properties:
        bound: { type: boolean }
        openid_masked: { type: string, description: 脱敏 openid（前4位+***+后3位），完整 openid 不回显 }
        unionid_present: { type: boolean }

    OrderStatusResponse:
      type: object
      required: [order_id, status, code_url_expired]
      properties:
        order_id: { type: string, format: uuid }
        status: { type: string, description: 本地订单状态（pending 时可能已按微信侧回查刷新） }
        trade_state: { type: string, nullable: true, description: 微信侧 trade_state（活跃查单回填） }
        code_url: { type: string, nullable: true, description: 未过期的 Native 二维码；过期后为 null }
        code_url_expired: { type: boolean }

    RefundCreateRequest:
      type: object
      required: [refund_amount_cents, out_refund_no]
      properties:
        refund_amount_cents:
          type: integer
          exclusiveMinimum: 0
          description: 退款金额（分）；预检 Σ(既有退款)+本次 ≤ 订单金额
        reason: { type: string, maxLength: 80, nullable: true }
        out_refund_no:
          type: string
          minLength: 8
          maxLength: 64
          description: 客户端持有的幂等键；重试必须稳定复用

    RefundResponse:
      type: object
      required: [refund_id, out_refund_no, order_id, status, refund_amount_cents, total_amount_cents]
      properties:
        refund_id: { type: string, format: uuid }
        out_refund_no: { type: string }
        order_id: { type: string, format: uuid }
        status: { type: string, description: 退款状态机（requested / SUCCESS / CLOSED 等） }
        refund_amount_cents: { type: integer }
        total_amount_cents: { type: integer, description: 原订单金额（分） }
        wechat_refund_id: { type: string, nullable: true }
        reason: { type: string, nullable: true }

    # PP-1 (ADR-0054 D4): C 端积分卡兑换 —— 契约与 handler 同包
    PointsCardRedeemRequest:
      type: object
      required: [card_code, card_password, project_id]
      description: |
        tenant_id / user_id 只取 JWT（无 body 字段）。card_code 服务端以
        HMAC-SHA256(card_code, secret) 计算后查 card_code_hash；card_password 以
        恒定时间比对 password_hash（与卡码解耦，L2-C §11.1）。
      properties:
        card_code:
          type: string
          minLength: 12
          maxLength: 64
          pattern: '^[A-Za-z0-9-]+$'
          description: 卡面明文卡码（不落库不进日志）
        card_password:
          type: string
          minLength: 8
          maxLength: 128
          description: 卡密明文（不落库不进日志）
        project_id:
          type: string
          format: uuid
          description: 显式项目作用域；须属 JWT 租户，不符统一 404（防枚举）

    PointsCardRedeemEnvelope:
      type: object
      required: [code, message, data]
      description: 统一 Envelope 包裹兑换结果
      properties:
        code: { type: string, enum: [SUCCESS] }
        message: { type: string }
        data: { $ref: '#/components/schemas/PointsCardRedeemResult' }

    PointsCardRedeemResult:
      type: object
      required: [card_id, batch_id, face_value, long_term_available_after, expires_at, idempotent_replay]
      properties:
        card_id: { type: string, format: uuid }
        batch_id:
          type: string
          format: uuid
          description: 入账生成的 long_term_points_batches 行（source='points_card'）
        face_value: { type: integer, minimum: 1, description: 卡面额（入账积分数） }
        long_term_available_after: { type: integer, minimum: 0, description: 兑换后长效账户可用余额 }
        expires_at:
          type: string
          format: date-time
          description: 批次到期时刻（兑换时刻 + LONG_TERM_POINTS_VALIDITY_YEARS，默认 2 年）
        idempotent_replay:
          type: boolean
          description: true = 同 Idempotency-Key 重放，返回首结果未重复入账

    # PP-2: scene 扩展画像（scene_profiles 表，与 student_profiles 分表）
    SceneProfileEnvelope:
      type: object
      required: [code, message, data]
      description: 统一 Envelope（development_standard §2.3.1）包裹 SceneProfileView
      properties:
        code: { type: string, enum: [SUCCESS] }
        message: { type: string }
        data: { $ref: '#/components/schemas/SceneProfileView' }

    SceneProfileView:
      type: object
      required: [profile_id, project_id, scene_code, schema_version, profile_data, updated_at]
      properties:
        profile_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        scene_code: { type: string, description: 场景代码（allowlist 内） }
        schema_version: { type: integer, minimum: 1 }
        profile_data: { type: object, additionalProperties: true, description: 场景自定义画像键值（浅合并语义） }
        updated_at: { type: string, format: date-time }

    SceneProfilePatchRequest:
      type: object
      required: [patch]
      properties:
        patch:
          type: object
          additionalProperties: true
          description: 顶层键浅合并（同键覆盖）
        remove_keys:
          type: array
          items: { type: string, maxLength: 128 }
          description: patch 合并之后应用删除的顶层键（remove 赢）
        reason: { type: string, maxLength: 200, nullable: true }

    MembershipGrantRequest:
      type: object
      required: [user_id, project_id, plan_id, months]
      properties:
        user_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        plan_id: { type: string, format: uuid }
        months: { type: integer, minimum: 1, maximum: 36 }
        auto_renew: { type: boolean, default: false }

    MembershipRevokeRequest:
      type: object
      required: [user_id, project_id]
      properties:
        user_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }

    Membership:
      type: object
      required:
        [id, tenant_id, user_id, project_id, plan_id, valid_from, valid_until,
         auto_renew, status]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        plan_id: { type: string, format: uuid }
        valid_from: { type: string, format: date-time }
        valid_until: { type: string, format: date-time }
        auto_renew: { type: boolean }
        status: { type: string, enum: [active, expired, cancelled] }
        cancelled_at: { type: string, format: date-time, nullable: true }

    MembershipPlanCreateRequest:
      type: object
      required: [project_id, name, monthly_points_quota]
      properties:
        project_id: { type: string, format: uuid }
        name: { type: string, minLength: 1, maxLength: 128 }
        monthly_points_quota: { type: integer, minimum: 0 }
        rollover_policy:
          type: string
          enum: [none, cap, full]
          default: none

    MembershipPlan:
      type: object
      required: [id, tenant_id, project_id, name, monthly_points_quota, rollover_policy, status]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        name: { type: string }
        monthly_points_quota: { type: integer }
        rollover_policy: { type: string }
        status: { type: string }

    # =====================================================================
    # T2 (ADR-0044 D2): API-key schemas
    # =====================================================================

    ApiKeyCreateRequest:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 128 }

    ApiKey:
      type: object
      required: [id, tenant_id, name, key_prefix, status]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        name: { type: string }
        key_prefix: { type: string, maxLength: 8 }
        status: { type: string, enum: [active, revoked] }
        created_at: { type: string, format: date-time, nullable: true }
        last_used_at: { type: string, format: date-time, nullable: true }
        revoked_at: { type: string, format: date-time, nullable: true }

    ApiKeyCreated:
      allOf:
        - $ref: '#/components/schemas/ApiKey'
        - type: object
          required: [api_key]
          properties:
            api_key:
              type: string
              description: 明文 key（ahk_ 前缀），仅创建/轮转响应返回一次

    # =====================================================================
    # T3 (ADR-0045): Skill schemas
    # =====================================================================

    SkillCreateRequest:
      type: object
      required: [name, slug, content_md]
      properties:
        project_id: { type: string, format: uuid, nullable: true }
        name: { type: string, minLength: 1, maxLength: 128 }
        slug:
          type: string
          minLength: 1
          maxLength: 128
          pattern: '^[a-z0-9][a-z0-9\-_]*$'
        description: { type: string, nullable: true }
        content_md: { type: string, minLength: 1 }

    SkillUpdateRequest:
      type: object
      properties:
        name: { type: string, minLength: 1, maxLength: 128 }
        description: { type: string, nullable: true }
        content_md: { type: string, minLength: 1 }
        status: { type: string, enum: [enabled, disabled] }

    Tool:
      type: object
      description: 工具/MCP 目录读模型（CV2-3/CV5-1，ADR-0051 D1）
      required: [id, tenant_id, name, slug, type, config_jsonb, status]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid, nullable: true }
        name: { type: string }
        slug: { type: string }
        type:
          type: string
          enum: [builtin, http, mcp_server]
        config_jsonb:
          type: object
          additionalProperties: true
          description: >-
            按类型：http={url, method?, timeout_s?}；mcp_server={url, auth_ref?,
            enable_tools?, timeout_s?}（凭据仅 OpenBao 引用）；builtin={}
        status:
          type: string
          enum: [enabled, disabled]

    ToolCreateRequest:
      type: object
      required: [name, slug, type]
      properties:
        project_id: { type: string, format: uuid, nullable: true }
        name: { type: string, maxLength: 200 }
        slug: { type: string, maxLength: 100, pattern: '^[a-z0-9][a-z0-9_-]*$' }
        type:
          type: string
          enum: [builtin, http, mcp_server]
        config_jsonb:
          type: object
          additionalProperties: true
          default: {}

    ToolUpdateRequest:
      type: object
      properties:
        name: { type: string, maxLength: 200 }
        config_jsonb:
          type: object
          additionalProperties: true
        status:
          type: string
          enum: [enabled, disabled]

    Skill:
      type: object
      required: [id, tenant_id, name, slug, content_md, status]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid, nullable: true }
        name: { type: string }
        slug: { type: string }
        description: { type: string, nullable: true }
        content_md: { type: string }
        status: { type: string, enum: [enabled, disabled] }
        created_at: { type: string, format: date-time, nullable: true }
        updated_at: { type: string, format: date-time, nullable: true }

    # =====================================================================
    # T4: tenant overview + dry-run + model connectivity test schemas
    # =====================================================================

    TenantOverviewRow:
      type: object
      required:
        [tenant_id, tenant_name, tenant_status, project_count, agent_count,
         long_term_points_available, tasks_7d, cost_cents_7d, tokens_7d]
      properties:
        tenant_id: { type: string, format: uuid }
        tenant_name: { type: string }
        tenant_status: { type: string }
        project_count: { type: integer }
        agent_count: { type: integer }
        long_term_points_available: { type: integer }
        tasks_7d: { type: integer }
        cost_cents_7d: { type: integer }
        tokens_7d: { type: integer }

    TenantOverview:
      type: object
      required: [tenants, generated_at]
      properties:
        tenants:
          type: array
          items: { $ref: '#/components/schemas/TenantOverviewRow' }
        generated_at: { type: string, format: date-time }

    AgentDryRunRequest:
      type: object
      required: [agent_id, bindings]
      properties:
        agent_id: { type: string, format: uuid }
        bindings:
          type: object
          additionalProperties: true
          description: AgentResourceBindings（与发布快照 bindings 同形）
        sample_input_tokens: { type: integer, default: 0 }

    AgentDryRunResult:
      type: object
      required:
        [agent_id, estimated_input_tokens, estimated_output_tokens, estimated_cost_cents]
      properties:
        agent_id: { type: string, format: uuid }
        estimated_input_tokens: { type: integer }
        estimated_output_tokens: { type: integer }
        estimated_cost_cents: { type: integer }
        notes:
          type: array
          items: { type: string }

    ModelConnectivityTestRequest:
      type: object
      required: [api_url]
      properties:
        api_url: { type: string, minLength: 1, maxLength: 2048 }

    ModelConnectivityTestResult:
      type: object
      required: [model_id, ok, warnings]
      properties:
        model_id: { type: string, format: uuid }
        ok: { type: boolean }
        warnings:
          type: array
          items: { type: string }

    # =====================================================================
    # CV1-2: Conversation 会话域（ADR-0049；headless API-key 委托通道）
    # =====================================================================

    ContentBlock:
      type: object
      required: [type]
      additionalProperties: true
      description: |
        消息内容块协议 v1。`type` 为判别字段（必填）；`text` 仅 text 块携带。
        其余字段按块类型透传落库（file / page_ref / activity 实解析归 CV4-3，
        card 块归 CV2-4），故保留 additionalProperties: true。
      properties:
        type:
          type: string
          enum: [text, file, page_ref, activity]
          description: 块类型判别字段（card 归 CV2-4）
        text:
          type: string
          description: 文本载荷（type=text 时由 runner 消费；mock echo 原样回包）

    ConversationCreateRequest:
      type: object
      required: [agent_id]
      properties:
        agent_id:
          type: string
          format: uuid
          description: 目标 Agent；须存在且 active_version 已发布（404 NOT_FOUND / 409 CONVERSATION_AGENT_NOT_PUBLISHED）
        client_conversation_key:
          type: string
          maxLength: 256
          nullable: true
          description: |
            客户端幂等键；(tenant_id, user_id, agent_id, client_conversation_key) 部分唯一
            （uk_conversation_client_key），命中冲突返回既有会话 200；不携带则不查重
        idle_timeout_seconds:
          type: integer
          minimum: 1
          nullable: true
          description: 空闲超时秒数；缺省 1800（beat 超时闭合归 CV1-3）

    ConversationOut:
      type: object
      required:
        [id, tenant_id, project_id, user_id, agent_id, agent_version, status,
         idle_timeout_seconds, last_turn_at, turn_count, total_points_charged,
         closed_at, created_at, updated_at, client_conversation_key]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        user_id:
          type: string
          format: uuid
          description: On-Behalf-Of 影子用户
        agent_id: { type: string, format: uuid }
        agent_version:
          type: integer
          description: 开会话时快照的 agent_versions.version
        status:
          type: string
          enum: [active, closing, closed]
        idle_timeout_seconds: { type: integer }
        last_turn_at: { type: string, format: date-time, nullable: true }
        turn_count: { type: integer, minimum: 0 }
        total_points_charged:
          type: integer
          minimum: 0
          description: 计费接线归 CV3，当前恒 0
        closed_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        client_conversation_key: { type: string, nullable: true }

    TurnCreateRequest:
      type: object
      required: [content_blocks]
      properties:
        content_blocks:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/ContentBlock' }

    TurnOut:
      type: object
      required:
        [id, conversation_id, seq, status, operation_id, points_charged,
         input_digest, output_digest, error_code, started_at, ended_at, messages]
      properties:
        id: { type: string, format: uuid }
        conversation_id: { type: string, format: uuid }
        seq: { type: integer, minimum: 1 }
        status:
          type: string
          enum: [created, frozen, generating, succeeded, failed, cancelled]
        operation_id:
          type: string
          description: 幂等键 `turn:{turn_id}`
        points_charged:
          type: integer
          nullable: true
          description: CV3 计费接线前为 0 占位
        input_digest: { type: string, nullable: true }
        output_digest: { type: string, nullable: true }
        error_code: { type: string, nullable: true }
        started_at: { type: string, format: date-time, nullable: true }
        ended_at: { type: string, format: date-time, nullable: true }
        messages:
          type: array
          items: { $ref: '#/components/schemas/MessageOut' }

    MessageOut:
      type: object
      required: [id, turn_id, conversation_id, role, content_blocks, created_at]
      properties:
        id: { type: string, format: uuid }
        turn_id: { type: string, format: uuid }
        conversation_id: { type: string, format: uuid }
        role:
          type: string
          enum: [user, assistant, tool]
        content_blocks:
          type: array
          items: { $ref: '#/components/schemas/ContentBlock' }
        created_at: { type: string, format: date-time }

    MessagePageOut:
      type: object
      required: [items, limit, offset]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/MessageOut' }
        limit: { type: integer }
        offset: { type: integer }

    TranscriptionOut:
      type: object
      required: [transcription_id, text, duration_seconds, points_charged]
      properties:
        transcription_id:
          type: string
          format: uuid
          description: 转写调用幂等键载体（operation_id = `stt:{transcription_id}`）
        text: { type: string }
        duration_seconds:
          type: number
          description: 音频时长（秒）；护栏 max_audio_seconds 的判据
        points_charged:
          type: integer
          description: 按次固定扣点（points_per_transcription）

    MessageSpeechOut:
      type: object
      required: [speech_id, file_id, url, mime_type, size_bytes, cached, points_charged]
      properties:
        speech_id:
          type: string
          format: uuid
          description: 合成调用幂等键载体（operation_id = `tts:{speech_id}`）
        file_id: { type: string, format: uuid }
        url:
          type: string
          description: storage_files 签名下载 URL（短 TTL；缓存命中返回同 URL）
        mime_type:
          type: string
          enum: [audio/mpeg]
        size_bytes: { type: integer }
        cached:
          type: boolean
          description: true = 缓存命中（零扣点）
        points_charged:
          type: integer
          description: 本次扣点（缓存命中恒 0）

    AttachmentAuthorizeIn:
      type: object
      required: [filename, mime, size_bytes]
      properties:
        filename: { type: string, maxLength: 256 }
        mime:
          type: string
          description: 会话级子集白名单（application/pdf / text/plain / text/markdown / text/x-markdown / text/csv / image/jpeg / image/png / image/gif / image/webp）
        size_bytes:
          type: integer
          description: 字节上限 CONVERSATION_ATTACHMENT_MAX_UPLOAD_BYTES（默认 10MB；外层仍有 FILE_UPLOAD_MAX_BYTES）

    AttachmentAuthorizeOut:
      type: object
      required: [fid, upload_url, bucket, expires_at]
      properties:
        fid: { type: string, description: 上传能力凭证（直传 URL 的路径段） }
        upload_url: { type: string, format: uri, description: SeaweedFS 直传目标（浏览器 PUT 原始字节；无额外 token） }
        bucket: { type: string, description: 租户 slug 桶（FileService 语义） }
        expires_at: { type: string, format: date-time }

    AttachmentCommitIn:
      type: object
      required: [fid, mime, size_bytes, sha256, filename]
      properties:
        fid: { type: string, maxLength: 256 }
        mime: { type: string }
        size_bytes: { type: integer }
        sha256:
          type: string
          pattern: '^[0-9a-f]{64}$'
          description: 客户端自报内容哈希（幂等键；服务端不重算）
        filename: { type: string, maxLength: 256 }

    AttachmentCommitOut:
      type: object
      required: [attachment_id, file_id, filename, kind, mime_type, size_bytes, excerpt, extraction_status, reused]
      properties:
        attachment_id: { type: string, format: uuid, description: conversation_attachments 行 id（下载鉴权依据） }
        file_id: { type: string, format: uuid }
        filename: { type: string }
        kind: { type: string, enum: [text, markdown, csv, pdf, image] }
        mime_type: { type: string }
        size_bytes: { type: integer }
        excerpt:
          type: string
          description: 服务端抽取摘录（≤4000 字符）；组装 CV4-3 file 输入块后经 `<data>` 包装进模型
        extraction_status:
          type: string
          enum: [ok, no_text_layer, vision_deferred]
          description: no_text_layer=扫描版/损坏 pdf；vision_deferred=图片（MM4 前占位）
        reused:
          type: boolean
          description: true = sha256/会话幂等重放（复用既有行，不重复落库）

    MembershipItem:
      type: object
      required: [tenant_id, tenant, user_id, primary_role, status, is_active]
      properties:
        tenant_id:
          type: string
          format: uuid
          description: 域（租户）ID
        tenant:
          type: object
          required: [id, name, slug, status]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            slug: { type: string }
            status: { type: string, enum: [active, suspended, deleted] }
          description: 租户摘要（不含 scope / 权限，§R6）
        user_id:
          type: string
          format: uuid
          description: 该域下的本地用户 ID
        primary_role:
          type: string
          description: 该域下的主角色代码
        status:
          type: string
          enum: [active, suspended, deleted]
          description: 该域用户行状态
        is_active:
          type: boolean
          description: |
            是否为当前活跃域（与 JWT tenant_id claim 一致）。
            一次只有一个归属的 is_active 为 true。

    ConversationFileOut:
      type: object
      required: [file_id, url, mime_type, size_bytes]
      properties:
        file_id: { type: string, format: uuid }
        url:
          type: string
          format: uri
          description: storage_files 短 TTL 签名下载 URL（HMAC token；与 KB/语音下载同路）
        mime_type: { type: string }
        size_bytes: { type: integer }
