# 面向 AI 代理的 GraphQL 变更如何抵御破坏性调用

生成的 GraphQL 请求往往以一种可预测的方式失败：模型拥有足够的信息来生成有效语法，却没有足够的阻力来避免危险操作。对人来说，一个名为 `updateProject`、带有可选 `archived` 标志的变更看起来很灵活。对一个根据不完整上下文组装请求的代理来说，它却可能把日常编辑变成改变项目状态的副作用入口。

应该把这种阻力放进模式和解析器，而不是写在要求代理小心行事的提示词里。类型化输入、狭窄的操作权限、有实际意义的预览、有条件的写入，以及带有恢复路径的错误，都能让正确请求比破坏性请求更容易生成。这种设计同样能帮助普通客户端开发者。代理只是把宽松 API 多年来容忍的捷径暴露了出来。

## 有效请求仍然可能表达错误操作

GraphQL 会检查请求是否符合模式，但不会确认调用者是否选中了正确的客户、是否理解记录状态，或是否真的想删除某些内容。团队常把类型安全误认为操作安全，然后把名为 `delete`、`archive` 或 `status` 的字段放进宽泛的更新变更中。

看看这个常见模式：

```graphql
input ProjectPatchInput {
  name: String
  description: String
  archived: Boolean
  ownerId: ID
}

type Mutation {
  updateProject(id: ID!, input: ProjectPatchInput!): Project!
}
```

它把无害编辑、所有权转移和生命周期转换混在了一起。代理收到「清理旧项目」的要求后，很容易推断应设置 `archived: true`。而当代理被要求修正项目名称时，也可能把早先生成对象中的 `archived` 字段一并保留下来。由于两者在结构上都正确，类型系统会接受这两个请求。

不要把破坏性行为藏在灵活补丁里。应为每个操作提供能说明后果的名称，并让输入只携带完成该操作所需的证据：

```graphql
type Mutation {
  renameProject(input: RenameProjectInput!): RenameProjectPayload!
  archiveProject(input: ArchiveProjectInput!): ArchiveProjectPayload!
  transferProjectOwnership(input: TransferProjectOwnershipInput!): TransferProjectOwnershipPayload!
}

input RenameProjectInput {
  projectId: ID!
  expectedVersion: Int!
  name: String!
}

input ArchiveProjectInput {
  projectId: ID!
  expectedVersion: Int!
  reason: ArchiveReason!
  confirmation: String!
  idempotencyKey: String!
}
```

这不是为了形式而增加形式。狭窄的变更会限制生成请求能够表达的内容，让「编辑标签」和「将其从正常使用中移除」产生清晰区别。工具描述可以解释这种区别，但真正的约束应由模式执行。

GraphQL 规范在这里能提供有限但有用的帮助。输入对象验证会拒绝模式中没有定义的输入字段。如果 `ArchiveProjectInput` 不包含 `ownerId`，客户端就不能把所有权变更偷偷塞进归档调用。应把这一点当作护栏，而不是安全边界。解析器仍需决定调用者是否有权归档这个特定项目。

避免使用 `action: String!` 这样的通用字段，例如 `mutateProject(action: "ARCHIVE")`。它看似简洁，但很快就会遇到每种操作需要不同字段、验证、授权、预览数据和错误处理的问题。最终结果会变成藏在输入对象里的私有 RPC 协议，也失去了 GraphQL 工具链的很多帮助。

## 输入必须说明目标和边界

破坏性输入应准确说明要改变什么、调用者检查过的是哪个版本，以及什么限制能阻止选择范围不断扩大。如果解析器可以扩展到子记录、外部系统或整个租户范围，仅有 ID 并不足以表达意图。

先使用能在调用者租户内定位单一资源的对象。除非产品确实需要批量操作，否则不要在删除变更中接受任意筛选条件。`where: { status: INACTIVE }` 这样的筛选条件会带来歧义：依据哪个时间戳判断不活跃，属于哪个租户，隐藏的默认条件又是什么？模型可能只是因为字段存在就填入它，并不是因为它检查过最终集合。

对于单记录操作，应在输入中携带版本令牌。整数容易检查，不透明的修订字符串也可以。解析器要在写入变更的同一事务中，将它与存储版本比较。如果不同，就返回冲突且不修改任何内容。

```graphql
input ArchiveProjectInput {
  projectId: ID!
  expectedVersion: Int!
  reason: ArchiveReason!
  confirmation: String!
  idempotencyKey: String!
}

enum ArchiveReason {
  CUSTOMER_REQUEST
  DUPLICATE
  END_OF_LIFE
}
```

`reason` 枚举不仅能改善报告，还能阻止请求编造自由文本理由，而后续自动化可能把这些理由当成有意义的信号。人们需要备注时可以使用自由文本，但操作分类应保持可枚举。

确认字段应绑定到实际目标。要求输入字面量 `ARCHIVE` 只能发现粗心构造。要求输入 `archive acme-project-42` 则迫使客户端解析并重复资源标识符。这不能阻止恶意客户端，也绝不能取代授权，但可以捕获大量把正确操作关联到错误 ID 的生成请求。

不要为修改显示名称等日常变更要求确认。过多确认会训练代理和人们机械地填写所有字段。把确认保留给具有实质性影响或难以逆转的操作，例如删除、发布、资金转移、撤销凭据，以及会影响其他用户的变更。

批量操作应明确上限，并返回绑定到精确选择结果的预览令牌。这个输入比原始筛选条件表达得清楚得多：

```graphql
input DeleteDormantProjectsInput {
  previewToken: ID!
  expectedCount: Int!
  confirmation: String!
  idempotencyKey: String!
}
```

执行解析器必须拒绝已过期、属于其他调用者、描述不同租户，或产生与 `expectedCount` 不同数量的令牌。否则代理可能先预览五条记录，执行时却运行一个已经匹配到五十条记录的动态查询。

## 权限应跟随变更，而不是跟随名词

`projects:write` 这样的权限范围通常过于宽泛，不适合自主操作。它可能让调用者在一个权限下重命名、归档、转移、删除项目，甚至修改与计费有关的项目设置，仅仅因为这些操作都涉及项目。这样的分组遵循数据库名词，而不是操作风险。

授予描述具体操作的权限。例如，发布自动化服务令牌可以拥有 `project:rename` 和 `project:archive`，而支持流程两者都没有。单独的 `project:delete` 权限应当很少使用。如果身份系统无法提供足够细的权限范围，就增加与变更名称关联的服务器端能力检查，并将它记录在授权决策中。

权限范围从来不能单独决定访问权。每个解析器都应按明确顺序执行检查：

1. 验证调用者身份，并确定其租户和主体。
2. 检查主体是否拥有执行此变更的权限。
3. 在租户边界内加载目标，而不是先全局加载、之后再检查。
4. 检查记录状态，以及业务规则要求的角色关系。
5. 执行有条件的写入，并在同一事务中追加审计事件。

在租户边界内加载很重要。解析器如果在验证租户前调用 `findProjectById(id)`，就可能通过耗时或错误措辞泄露资源是否存在，也可能把全局加载的对象交给一个默认认为授权已经完成的辅助函数。应将租户成员关系放进查询条件。

不要根据代理声称的任务推断权限。写有 `X-Agent-Goal: cleanup` 的请求头只能作为审计线索，不能成为权限授予。提示词、任务标签和模型身份可以帮助人审查操作，但任何客户端都能伪造它们。

工具访问也遵循同一区别。代理可能有权访问 GraphQL 端点，却无权执行某个具体变更。在代理运行环境允许时，应分别描述读取工具和操作工具。最终决定必须留在 API 中，因为客户端可以绕过工具元数据，直接发送 HTTP 请求。

## dry run 必须建立与执行相同的计划

只有能回答「这个完全相同的请求现在会做什么」的 dry run 才有用。用简化查询计算行数的虚假预览会给代理带来错误安全感。真正的变更可能使用不同的资格规则、不同的授权分支，或触发预览从未考虑的外部操作。

建立共享的计划函数。它接收经过身份验证的调用者和输入，验证所有条件，解析目标，计算副作用，并生成不可变计划。预览返回该计划的经过清理的表示。执行路径只有在调用者提供短期令牌和确认信息后，才能使用这份计划。

```graphql
type Mutation {
  previewArchiveProject(input: PreviewArchiveProjectInput!): ArchivePreviewPayload!
  archiveProject(input: ArchiveProjectInput!): ArchiveProjectPayload!
}

input PreviewArchiveProjectInput {
  projectId: ID!
  expectedVersion: Int!
  reason: ArchiveReason!
}

type ArchivePreviewPayload {
  previewToken: ID!
  project: Project!
  affectedMemberCount: Int!
  plannedEffects: [ArchiveEffect!]!
  expiresAt: DateTime!
}

enum ArchiveEffect {
  PROJECT_HIDDEN_FROM_DEFAULT_LISTS
  PENDING_INVITATIONS_CANCELLED
}
```

计划应包含目标 ID、目标版本、调用者身份、租户、输入摘要、计划中的影响和过期时间。可以将计划保存在服务器端，也可以签发引用服务器端状态的不透明令牌。不要把完整计划放进客户端控制的 JSON 中，再在执行时直接信任它。

执行输入应引用预览令牌，而不是重复一个宽松选择器：

```graphql
input ArchiveProjectInput {
  previewToken: ID!
  confirmation: String!
  idempotencyKey: String!
}
```

这种两次调用的流程会增加阻力，而这正是后果重大的操作所需要的。不要把它用于每个变更。一个简单规则是：当操作影响多条记录、具有不可逆的外部影响，或会让其他用户无法使用资源时，要求预览。

预览响应也需要访问控制。返回受影响记录列表，和执行变更一样可能泄露数据。计划阶段要应用相同的租户和角色规则。预览可以隐藏调用者无权读取的字段，同时返回决策所需的数量和影响类别。

## 幂等性和版本解决的是不同故障

幂等性防止同一个请求被重复应用。版本检查防止请求应用到调用者查看之后已经改变的状态。团队经常加入其中一个，就以为两个问题都解决了。

HTTP 连接可能在服务器提交变更后断开，代理因此发起重试。没有幂等性，第二个请求可能再次退款、重复发送消息，或两次调用同一个外部 API。每个有副作用的变更都应要求调用者提供 `idempotencyKey`。服务器应将它与经过身份验证的主体、变更名称、规范化输入摘要，以及已完成的响应或稳定错误一起保存。

当服务器再次看到相同主体、变更、密钥和输入摘要时，应返回原始结果。如果同一个密钥对应不同摘要，则返回 `IDEMPOTENCY_KEY_REUSED`，不执行任何操作。允许复用密钥但更换输入，会破坏客户端依赖的重试保证。

版本检查处理的是另一种时序。代理读取到项目版本 7，准备归档预览；随后人类重命名项目，或恢复了一项邀请。归档执行时，解析器将版本 7 与当前存储版本比较。如果当前值是 8，就返回冲突。代理必须重新读取状态、重新考虑意图，并在需要时生成新预览。

GraphQL 规范会在一个变更操作中串行执行顶层字段，但不会让不同 HTTP 请求串行化。两个代理仍可能几乎同时提交两个变更操作。应使用数据库条件更新、行锁或事务约束。先在应用内存中检查，再单独写入，会留下竞争窗口。

```sql
UPDATE projects
SET archived_at = CURRENT_TIMESTAMP,
    version = version + 1
WHERE id = :project_id
  AND tenant_id = :tenant_id
  AND version = :expected_version
  AND archived_at IS NULL;
```

如果影响行数为零，应在租户边界内检查当前记录，并返回具体结果：不存在、禁止访问、已经归档或版本冲突。不要把所有零行结果都报告为通用服务器错误。代理需要知道重试是有害、有用，还是毫无意义。

## 错误响应应告诉代理下一步做什么

GraphQL 顶层 `errors` 数组适合解析错误、验证失败和解析器失败，但不适合迫使客户端抓取英文消息来判断业务结果。应在类型化载荷中返回预期的变更结果，并提供稳定代码和结构化详情。

```graphql
type ArchiveProjectPayload {
  outcome: ArchiveProjectOutcome!
  project: Project
  error: MutationError
}

enum ArchiveProjectOutcome {
  ARCHIVED
  VERSION_CONFLICT
  CONFIRMATION_REQUIRED
  PREVIEW_EXPIRED
  FORBIDDEN
  IDEMPOTENCY_KEY_REUSED
}

type MutationError {
  code: String!
  message: String!
  currentVersion: Int
  requiredConfirmation: String
}
```

对于客户端无法正确执行操作的情况，使用传输层和 GraphQL 执行错误。对于请求正常执行，但业务规则拒绝改变状态的情况，使用类型化结果。选择一种约定并记录下来。部分冲突使用 `errors.extensions.code`，另一部分使用载荷枚举，会让代理行为变得脆弱。

代理应能把结果映射到安全操作。`VERSION_CONFLICT` 表示重新读取对象并重新考虑；`PREVIEW_EXPIRED` 表示创建新预览；`CONFIRMATION_REQUIRED` 表示向人展示所需短语或请求该短语，而不是猜测；`FORBIDDEN` 表示停止；`IDEMPOTENCY_KEY_REUSED` 表示只有在调用者明确想执行不同操作后，才能生成新密钥。

不要返回内部策略名称、SQL 片段或授权图详情。稳定的外部代码可以足够精确，同时不暴露实现细节。在响应扩展中保留关联 ID，并在服务器上写入对应审计记录。代理报告失败时，操作人员就有明确线索可查。

成功载荷需要足够信息来结束不确定性。返回最终记录状态、新版本、操作 ID，以及实际发生的影响。只有一个布尔值会迫使客户端再次查询，并留下读取旧状态的可能，也会让人工审查变得不必要地困难。

## 删除需要生命周期，而不是布尔开关

硬删除很受欢迎，因为它能让表看起来整洁。但当代理误解请求时，它也最容易造成无法恢复的支持事故。许多产品更适合先归档，在服务器控制下保留撤销期，再通过单独的受限流程永久清除。

如果归档操作只是隐藏记录，就不要把它叫作 `deleteProject`。名称会教客户端应该期待什么状态。`archiveProject` 应返回 `ARCHIVED`；`purgeProject` 则应表示数据将不再可用。当 API 用 delete 表示每个生命周期阶段时，代理无法可靠区分可恢复的清理和永久删除。

永久清除需要比归档更严格的输入和授权。它可能要求资源已经归档达到保留期限、不存在法律或计费保留，并由拥有独立权限的操作人员批准。解析器必须执行每项条件。客户端倒计时或工具指令没有权限。

外部副作用也应以同样方式处理。如果归档会取消邀请、移除远程环境或触发 webhook，就应在预览和最终载荷中返回这些影响。不要把它们悄悄附加到通用更新解析器上。审查代理请求的人需要在批准前看到后果，代理也需要在执行后拥有可以报告的事实。

对于资金或凭据操作，不要提供一种会调用供应商线上端点、再希望它不产生影响的假 dry run。如果供应商提供正式的预览或授权机制，应使用它。否则要明确说明结果只是本地估计，并列出服务器无法验证的内容。假装确定，比要求人工决策更糟糕。

## 解析器检查必须让模式承诺真正生效

模式设计可以限制格式错误的意图。解析器设计则能阻止一个看似已获授权的请求越过真实边界。应在代码中分开维护这两层，避免未来重构时用工具定义中的注释替代权限检查。

破坏性操作的解析器应采用一种让拒绝成本低、写入尽量晚的顺序。先验证请求，再解析调用者租户，验证输入，在租户内加载目标，检查权限范围和状态，验证预览令牌与确认信息，声明幂等记录，最后执行条件事务。具体顺序可以根据存储模型调整，但在确定事务能够提交前，不要执行外部操作。

当工作跨越数据库和外部供应商时，幂等记录需要谨慎处理。在外部调用前把密钥标记为完成，可能在调用失败时错误地声称成功；先调用供应商，则可能在进程崩溃、还未保存完成状态时造成重复。条件允许时，应使用 outbox 模式或供应商提供的幂等支持。记录持久化的待处理操作，提交本地决策，再使用能跨重试保留的操作 ID 分发外部影响。

审计记录应包含经过身份验证的主体、适用时的代理运行 ID、变更名称、规范化目标、输入摘要、授权结果、预览引用、幂等键、结果和最终版本。根据保留规则，对包含敏感内容的备注和字段进行脱敏。只写「变更成功」的审计事件，在事故中几乎没有用。

对于通过带凭据的 HTTP 或 SSH 调用执行操作的代理，应尽量把凭据放在模型进程之外。Sallyport 会将受支持的操作路由到本地保险库，并记录每次调用。当 GraphQL 变更需要超出 bearer token 的、对人可见的授权时，这种方式很有帮助。

## 测试生成的请求，而不只是解析器

只用精心构造的对象调用解析器进行单元测试，会错过真正关心的故障模式。生成客户端会发送被省略的字段、空值、过期 ID、别名、重复请求，以及根据前一个工具输出组装的变量。应使用相同形状，在公开 GraphQL 边界测试。

围绕行为而不是代码分支建立变更测试矩阵。至少覆盖：来自其他租户的调用者、拥有读取权限但没有操作权限的调用者、过期预览、目标版本改变、错误确认字符串、重放的幂等键，以及两个使用同一预期版本的并发调用。每次测试都要断言响应和持久化状态。

由于输入没有定义 `ownerId`，下面的请求应在 GraphQL 验证阶段失败：

```graphql
mutation BadArchive($input: ArchiveProjectInput!) {
  archiveProject(input: $input) {
    outcome
  }
}
```

```json
{
  "input": {
    "projectId": "prj_42",
    "expectedVersion": 7,
    "reason": "DUPLICATE",
    "confirmation": "archive prj_42",
    "idempotencyKey": "run-18-archive-42",
    "ownerId": "usr_9"
  }
}
```

预期响应应使用顶层 GraphQL 错误格式，因为文档提供了无效的输入对象。这个测试证明模式能将无关能力排除在变更之外。还需要单独测试格式正确的归档请求在调用者属于其他租户时仍会失败。

并发测试应针对真实事务行为，而不是内存假实现。发送两个使用相同 ID 和预期版本的归档请求，然后断言一个返回 `ARCHIVED`，另一个返回冲突或幂等重放结果。如果两个调用都报告成功且操作 ID 不同，说明条件写入没有发挥作用。

也要测试审计验证。如果操作网关生成具备防篡改能力的加密审计轨迹，就应把验证纳入事故演练，而不是把它当作没人使用的命令。Sallyport 提供 `sp audit verify`，无需保险库密钥即可离线验证哈希链；可以对复制出的日志运行它，并确保操作人员知道验证失败意味着什么。

## 生成工具需要更少选择，而不是更长警告

当工具模式提供与任务匹配的最小安全操作时，代理表现更好。包含通用筛选器、标志和可选副作用的庞大变更目录，会迫使模型从字段名称推断策略。由明确的读取、预览、执行和恢复操作组成的紧凑目录，能为代理提供可遵循的路径。

提供读取操作，返回代理在提出变更前所需的标识符、版本、状态和名称。如果代理必须从人类标签猜 ID，变更设计也救不了你。清楚地返回稳定 ID，并明确表示搜索结果存在歧义，而不是悄悄选择其中一个。

工具描述应说明前置条件和后果，但服务器仍是执行约束的地方。例如：「在预览成功后归档一个项目。它会取消预览中列出的待处理邀请。」这比「请谨慎使用」好得多，后者不会告诉代理任何可执行的信息。

不要试图用人工审批对话框解决所有风险。当人确实拥有决策权时，审批很合适，但重复提示最终会变成背景噪声。把日常保护放进权限范围、租户检查、版本和幂等性中。对于无法从数据推断意图、后果永久存在，或会跨越组织边界的少数操作，再请人审查。

从最可能造成伤害的变更开始：代理重复调用它、针对过期状态调用它，或针对错误租户调用它时会怎样？拆分输入，在操作需要时加入真正的预览，让写入具备条件，并编写重放测试。这些工作会暴露你的 API 到底清楚地建模了操作，还是只是在暴露数据库字段。
