{
 "version": "0.1.0",
 "siteUrl": "https://plumbrule.dev",
 "tags": {
  "all": "所有项目",
  "lang:ts": "TypeScript / JavaScript / Node",
  "lang:go": "Go",
  "lang:python": "Python",
  "lang:rust": "Rust",
  "lang:jvm": "Java / Kotlin（服务端）",
  "lang:shell": "脚本与配置",
  "platform:web": "Web 前端",
  "platform:android": "Android",
  "platform:ios": "iOS",
  "platform:desktop": "桌面应用",
  "platform:mobile": "移动端（跨平台）",
  "kind:service": "对外提供服务的后端",
  "kind:library": "供别人引用的库或 SDK",
  "kind:cli": "命令行工具",
  "kind:agent": "由编码 Agent 参与开发的仓库",
  "kind:llm": "调用大模型的应用",
  "kind:data": "数据分析与统计",
  "kind:onprem": "私有化或断网部署"
 },
 "rules": [
  {
   "id": "ARC-001",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "在仓库文档里写明项目类型、采用的架构风格、主要模块及负责人，以及哪些架构规则不适用及原因",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查架构说明文档是否存在并与代码结构一致；格式不限（Markdown 小节、YAML 清单都可以）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-001"
  },
  {
   "id": "ARC-002",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "按项目类型选起点：小 CLI 用清楚的模块加显式输入输出；有一定复杂度的业务后端默认从模块化单体起步",
   "level": "soft",
   "check": "manual",
   "how": "设计评审时检查是否和更简单的方案做过对比",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-002"
  },
  {
   "id": "ARC-003",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "拆独立服务前，写明它解决的具体问题，并证明能独立部署、回滚、扩容，有数据迁移和回切方案",
   "level": "soft",
   "check": "manual",
   "how": "拆分方案的 ADR 或 PR 里逐项回答下面六个问题，评审时核对",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-003"
  },
  {
   "id": "ARC-004",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "业务模块边界、部署边界、信任与故障边界分开判断，不因为要隔离某段代码就把业务拆成微服务",
   "level": "soft",
   "check": "manual",
   "how": "设计评审时检查架构图里三种边界是否分别标出",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-004"
  },
  {
   "id": "ARC-005",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "按业务能力和变化原因划分模块，不按 controller、service、repository 这类技术层横切全仓库",
   "level": "soft",
   "check": "manual",
   "how": "评审新模块时检查它是否对应一项业务能力，并有一个反例说明什么需求会证明拆法错了",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-005"
  },
  {
   "id": "ARC-006",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "多模块项目中，业务模块之间的编译期依赖必须无环，并在 CI 里用依赖检查工具强制",
   "level": "hard",
   "check": "auto",
   "how": "用所用语言的架构检查工具（见选型「架构边界检查」）在 CI 里检测模块依赖环，新增的环阻断合并",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-006"
  },
  {
   "id": "ARC-007",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "多模块项目中，跨模块只能引用对方公开的接口，不得导入对方的内部实现",
   "level": "hard",
   "check": "auto",
   "how": "在架构检查工具里把每个模块的公开路径登记出来，CI 阻断其他模块对非公开路径的引用",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-007"
  },
  {
   "id": "ARC-008",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "每个业务模块写明负责人、公开接口和它拥有的数据",
   "level": "soft",
   "check": "semi",
   "how": "CODEOWNERS 覆盖每个模块目录；评审时检查模块说明是否列出公开接口和数据",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-008"
  },
  {
   "id": "ARC-009",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "一个模块的数据只由该模块修改；其他模块要改，走它的公开入口，例外要登记并批准",
   "level": "hard",
   "check": "semi",
   "how": "按模块划分数据库表的访问权限或代码层的数据访问分组，扫描跨模块直接写表的代码；例外在评审时确认登记",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-009"
  },
  {
   "id": "ARC-010",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "被声明为核心领域的代码不直接依赖 HTTP、数据库、UI 框架或外部 SDK，通过接口和适配器接入",
   "level": "soft",
   "check": "auto",
   "how": "在架构检查工具里禁止核心领域目录导入这些包",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-010"
  },
  {
   "id": "ARC-011",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "架构检查器要有负例自测：故意违规的样例必须让检查失败，路径别名、生成代码、条件编译、懒加载导入不能静默漏检",
   "level": "soft",
   "check": "auto",
   "how": "在仓库里放一个违规样例目录，CI 断言检查器对它报错并返回非零退出码",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-011"
  },
  {
   "id": "ARC-012",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "静态依赖检查覆盖不到的地方（反射、动态加载、SQL 跨模块访问表、运行时消息环），用负例测试、数据库权限和评审补上",
   "level": "soft",
   "check": "semi",
   "how": "评审时列出本项目用到的动态机制，确认每一种都有对应的测试或权限控制",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-012"
  },
  {
   "id": "ARC-013",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "架构规则的豁免要登记编号、理由、负责人、影响范围和到期日；不得关闭或放宽检查器来让 CI 通过",
   "level": "hard",
   "check": "semi",
   "how": "检查器配置文件写进 CODEOWNERS；CI 检查豁免清单格式并对过期条目报错",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-013"
  },
  {
   "id": "ARC-014",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "必须一起成立的业务规则在同一个事务里校验并提交；跨不了一个事务的，显式设计补偿流程",
   "level": "hard",
   "check": "semi",
   "how": "用并发和事务回滚的集成测试验证不变量；评审时检查跨存储的流程是否写明补偿",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-014"
  },
  {
   "id": "ARC-015",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "数据库写入和发消息需要原子时，用事务 outbox：业务变更和待发记录在同一个事务里提交，不得提交后直接发消息",
   "level": "hard",
   "check": "semi",
   "how": "评审时查找「提交事务后直接调用消息客户端」的代码；用故障注入测试提交后进程崩溃的情况",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-015"
  },
  {
   "id": "ARC-016",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "至少投递一次的消费者必须幂等：持久化去重，去重记录和本地副作用在同一个事务里提交",
   "level": "hard",
   "check": "semi",
   "how": "用重复投递和回放测试，验证同一消息处理两次结果不变",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-016"
  },
  {
   "id": "ARC-017",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "跨进程调用必须设置超时，并写明失败语义：超时后结果未知（可能已成功），以及能否重试",
   "level": "hard",
   "check": "semi",
   "how": "评审调用封装是否都有超时；用故障测试模拟超时后对方实际已成功的情况",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-017"
  },
  {
   "id": "ARC-018",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "自动重试只用于暂时性失败，必须有次数上限、指数退避和随机抖动；没有幂等保证或对账手段的外部副作用不自动重试",
   "level": "hard",
   "check": "semi",
   "how": "检查重试配置是否都有上限和退避；评审时确认被重试的操作是幂等的",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-018"
  },
  {
   "id": "ARC-019",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "并发修改共享状态必须用版本号、锁或等价机制；冲突时返回可识别的错误，不静默覆盖",
   "level": "hard",
   "check": "semi",
   "how": "用并发更新测试验证两次同时修改时有一方收到冲突错误",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-019"
  },
  {
   "id": "ARC-020",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "授权、租户范围、业务状态和期望版本在真正写入数据的地方校验；前置检查、UI、HTTP 中间件或提示词都不能替代",
   "level": "hard",
   "check": "semi",
   "how": "写绕过前端和中间件、直接调用后台任务、消息消费者或内部接口的负例测试，确认被拒绝",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-020"
  },
  {
   "id": "ARC-021",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "统一的命令或授权入口只负责决策和公共记录，业务状态转换仍由所属模块执行，不做成什么都经过它写库的中心服务",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查统一入口是否直接操作其他模块的数据表",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-021"
  },
  {
   "id": "ARC-022",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "事件处理失败必须可见：进入死信或失败队列、有监控，并能重放或人工修复，不得静默丢弃",
   "level": "hard",
   "check": "semi",
   "how": "演练一次消费失败，确认消息进入失败队列、触发告警并能重放",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-022"
  },
  {
   "id": "ARC-023",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "核心链路的失败路径要有可执行测试：提交后响应丢失、超时结果未知、重复、乱序、进程重启、回放",
   "level": "soft",
   "check": "semi",
   "how": "评审时对照这份清单检查核心链路的测试用例",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-023"
  },
  {
   "id": "ARC-024",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "区分业务状态、审计记录、对外集成事件和运行事件，各自定保留期限、访问权限和演进方式",
   "level": "soft",
   "check": "manual",
   "how": "设计评审时检查每类事件的用途和保留策略是否写明",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-024"
  },
  {
   "id": "ARC-025",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "CQRS 从轻量做起：先在同一个数据库里把读和写的代码分开；有测量数据证明需要时才上独立读存储和异步投影",
   "level": "soft",
   "check": "manual",
   "how": "引入独立读存储的 ADR 里附上负载或查询性能数据",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-025"
  },
  {
   "id": "ARC-026",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "事件溯源只用于事件序列本身就是事实来源的子域，不为审计或操作记录而采用",
   "level": "soft",
   "check": "manual",
   "how": "采用事件溯源的 ADR 写明为什么状态表加审计日志不够",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-026"
  },
  {
   "id": "ARC-027",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "跨服务的长流程用 Saga 前，写清每一步的终态、超时、补偿、不可补偿的状态和人工恢复办法",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查 Saga 设计是否包含这五项",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-027"
  },
  {
   "id": "ARC-028",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "扩展点按需递进：一个实现时直接调用；有第二个真实实现再抽接口和适配器；有真实的第三方生态才做隔离的插件机制",
   "level": "soft",
   "check": "manual",
   "how": "新增扩展点或插件机制时，评审时确认已有第二个实现或明确的第三方需求",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-028"
  },
  {
   "id": "ARC-029",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "不可信代码和第三方插件不与核心服务共享权限：放在隔离的进程或沙箱里，只给最小范围、有时效的凭证",
   "level": "hard",
   "check": "semi",
   "how": "评审执行环境的权限配置；用越权和逃逸测试验证插件拿不到核心服务的凭证和数据",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-029"
  },
  {
   "id": "ARC-030",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "按隔离要求选租户模型（共享表、每租户 schema、每租户库、每租户独立部署或混合），写进 ADR；私有化内网客户先评估每客户一套部署",
   "level": "soft",
   "check": "manual",
   "how": "评审租户模型的 ADR 是否对比过隔离强度、成本和运维代价",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-030"
  },
  {
   "id": "ARC-031",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "共享实例的多租户系统，租户标识必须取自可信的身份上下文，隔离覆盖数据库、缓存键、搜索索引、对象存储、任务载荷、事件和导出，并有跨租户负例测试",
   "level": "hard",
   "check": "semi",
   "how": "跨租户矩阵测试：用租户 A 的身份访问租户 B 的数据，覆盖接口、后台任务、缓存和导出，全部必须失败",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-031"
  },
  {
   "id": "ARC-032",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "承诺离线或内网交付的产品，必须在无外网环境中完成安装、启动、核心功能、升级回滚测试；运行时不依赖在线 CDN、在线许可证验证或在线下载",
   "level": "hard",
   "check": "auto",
   "how": "CI 或发布流程里在断网的环境（如无外网的网络命名空间）跑冒烟测试，并记录外发请求",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-032"
  },
  {
   "id": "ARC-033",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "新增依赖或扩展点时写明真实需求、许可证，以及对离线交付的影响",
   "level": "soft",
   "check": "semi",
   "how": "评审时看依赖清单变化；许可证扫描按 27 的口径",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-033"
  },
  {
   "id": "ARC-034",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "引入新的架构模式、服务边界、消息中间件、事件存储、插件机制或租户模型时，必须有已批准的 ADR；不影响结构的小改动不写",
   "level": "hard",
   "check": "manual",
   "how": "评审时检查 PR 是否属于上述变更，属于则必须链接已接受的 ADR",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-034"
  },
  {
   "id": "ARC-035",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "架构改动按风险定设计深度：R0–R1 直接做；R2 写简短方案；R3 写 ADR 并由非作者评审",
   "level": "soft",
   "check": "manual",
   "how": "PR 里标出风险级别，评审时抽查",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-035"
  },
  {
   "id": "ARC-036",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "至少维护系统上下文图和容器图，图源放进版本库，标明依赖方向、同步或异步、信任边界",
   "level": "soft",
   "check": "semi",
   "how": "CI 校验文本图源语法；评审时对照模块清单检查图是否过时",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-036"
  },
  {
   "id": "ARC-037",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "性能、可用性、恢复时间等质量目标写成可测量的指标并有负责人，用测试或监控持续验证",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查每个质量目标是否有对应的测试或监控项",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-037"
  },
  {
   "id": "ARC-038",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "改动模块的公开接口或事件结构时，运行受影响的下游契约测试，并写明兼容窗口",
   "level": "soft",
   "check": "auto",
   "how": "CI 根据改动路径找出下游模块，运行它们的契约测试",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-038"
  },
  {
   "id": "ARC-039",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "改代码前先读相关 ADR 和模块公开接口；改动与已接受的 ADR 冲突，或无法用要求的检查验证时，停下并报告",
   "level": "soft",
   "check": "manual",
   "how": "评审时看 Agent 的交付说明是否引用了相关 ADR，冲突时是否有报告",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-039"
  },
  {
   "id": "ARC-040",
   "topic": "01",
   "topicTitle": "架构与设计原则",
   "title": "定期的架构巡检（人或工具）只产出报告或建议 PR，不自动做大规模重构",
   "level": "soft",
   "check": "manual",
   "how": "巡检工具的权限只到开 PR；评审时检查单个 PR 的改动范围",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/architecture/#arc-040"
  },
  {
   "id": "COD-001",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "同一个概念全项目用同一个名称，不同概念不共用名称；领域术语记进术语表",
   "level": "soft",
   "check": "manual",
   "how": "评审时对照术语表检查新出现的名称；术语表本身随代码一起评审",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-001"
  },
  {
   "id": "COD-002",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "标识符的大小写、前后缀遵循所用语言的惯例，不跨语言强行统一一种写法",
   "level": "hard",
   "check": "auto",
   "how": "用各语言的命名 lint 规则检查（工具见 10–15）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-002"
  },
  {
   "id": "COD-003",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "新的公共名称用通用英文词和业内公认的缩写，不用随意的拼音、中英混拼和自造缩写",
   "level": "soft",
   "check": "manual",
   "how": "评审新增的公共接口、字段和模块名；专有名词（如地名、法规名）登记在术语表里作为例外",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-003"
  },
  {
   "id": "COD-004",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "带单位的数量把单位写进名称或类型里，如 `timeout_ms`、`size_bytes`，或用语言自带的时长类型",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查新增的时长、大小、比例类字段和参数；能用强类型（如时长类型）的优先用类型",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-004"
  },
  {
   "id": "COD-005",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "有业务含义的数字和字符串定义成具名常量，写清含义、单位和来源",
   "level": "soft",
   "check": "semi",
   "how": "开启各语言的魔法数字规则提示候选（如 ESLint `no-magic-numbers`、Ruff `PLR2004`），评审确认",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-005"
  },
  {
   "id": "COD-006",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "注释写原因、取舍、约束和风险，不逐行复述代码；改行为时同步改注释",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查注释是否解释了「为什么」，以及改动处的注释是否还和代码一致",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-006"
  },
  {
   "id": "COD-007",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "供别人调用的库，公开接口必须有文档注释，写清用途、参数和返回值、可能的错误、前置条件和副作用",
   "level": "hard",
   "check": "semi",
   "how": "用各语言的文档缺失检查找出没写的公开接口（工具见 10–15），内容是否完整靠评审",
   "applies": [
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-007"
  },
  {
   "id": "COD-008",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "TODO、FIXME 要写原因并关联任务编号或负责人；不留占位实现冒充完成",
   "level": "soft",
   "check": "auto",
   "how": "lint 或脚本检查 TODO 是否带任务编号（如 Ruff 的 TD 规则、ESLint `no-warning-comments`）；评审时检查空实现和写死的返回值",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-008"
  },
  {
   "id": "COD-009",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "生成的代码在文件头标明由什么工具生成、不要手改；手写代码不放在生成目录里",
   "level": "soft",
   "check": "auto",
   "how": "检查生成文件头部的标记（如 Go 约定的 `// Code generated ... DO NOT EDIT.`），格式器和 lint 跳过这些文件",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-009"
  },
  {
   "id": "COD-010",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "不吞错：禁止空的 catch、捕获后什么都不做，或只打印一下就当成功继续",
   "level": "hard",
   "check": "semi",
   "how": "开启各语言的相关 lint（如空 catch、裸 except、未检查的返回错误），评审有意忽略的地方是否写了原因",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-010"
  },
  {
   "id": "COD-011",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "向上传递错误时保留原始错误，并补上操作、对象标识、所处阶段等上下文；不用新错误覆盖掉原错误",
   "level": "hard",
   "check": "semi",
   "how": "用语言自带的错误链（Go `%w` 和 `errors.Is/As`、Python `raise ... from`、JS `new Error(msg, { cause })`、Java/Kotlin 构造异常时传入 cause、Rust `source()`），评审检查是否丢了原错误",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-011"
  },
  {
   "id": "COD-012",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "只在能处理或恢复、要转换错误类型、要补有用上下文的地方捕获错误；其余情况向上传",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查每个捕获点属于哪一种情况",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-012"
  },
  {
   "id": "COD-013",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "区分三类错误：调用方能纠正的（输入非法、无权限、冲突）、临时故障（超时、限流、下游不可用）、程序缺陷；是否可重试单独标记",
   "level": "soft",
   "check": "manual",
   "how": "评审错误类型的定义和映射表",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-013"
  },
  {
   "id": "COD-014",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "调用方按错误类型或错误码判断，不靠匹配错误消息文本",
   "level": "soft",
   "check": "semi",
   "how": "搜索代码里对错误消息做字符串比较或正则匹配的地方，评审确认",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-014"
  },
  {
   "id": "COD-015",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "在服务或进程的边界把内部错误转成稳定的错误码和安全的说明，不把栈、SQL、内部路径、密钥返回给调用方",
   "level": "hard",
   "check": "semi",
   "how": "评审边界处的错误映射；用安全扫描找出直接返回异常信息的地方；对错误响应做样例测试",
   "applies": [
    "kind:service",
    "kind:library",
    "kind:cli"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-015"
  },
  {
   "id": "COD-016",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "生产环境的应用日志用结构化格式（如每行一个 JSON 对象），不拼接成只有人能读的句子",
   "level": "hard",
   "check": "auto",
   "how": "在测试或预发环境采样日志，检查每行都能被解析成字段",
   "applies": [
    "kind:service",
    "kind:cli"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-016"
  },
  {
   "id": "COD-017",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "每条日志都有时间戳、级别、服务名、事件名和消息；处理请求时带上追踪编号和请求编号，出错时带错误码",
   "level": "hard",
   "check": "auto",
   "how": "定义日志字段清单，用日志样本校验必备字段",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-017"
  },
  {
   "id": "COD-018",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "全项目统一日志级别的含义：DEBUG 诊断细节，INFO 正常的重要事件，WARN 异常但已恢复或已降级，ERROR 本次操作失败，FATAL 进程无法继续",
   "level": "soft",
   "check": "manual",
   "how": "评审新增日志的级别；抽查生产日志里 ERROR 是否都对应真实失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-018"
  },
  {
   "id": "COD-019",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "日志里不记录密码、令牌、密钥、Cookie、完整连接串、银行卡号和没有正当用途的个人信息；按字段白名单记录，不整个打印请求体",
   "level": "hard",
   "check": "semi",
   "how": "用密钥和个人信息检测扫描日志样本（工具见 25）；评审新增日志的字段",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-019"
  },
  {
   "id": "COD-020",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "写入日志的外部输入要经过编码，去掉或转义换行等控制字符，并限制长度",
   "level": "hard",
   "check": "semi",
   "how": "优先用结构化日志库自动编码字段；安全扫描找出把外部输入直接拼进日志消息的地方",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-020"
  },
  {
   "id": "COD-021",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "同一个错误只在处理它的那一层完整记录一次，中间层只传递不重复打印",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查一条错误路径上有几处记录日志；对照日志样本看是否同一错误出现多条",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-021"
  },
  {
   "id": "COD-022",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "随部署变化的配置不写死在代码里；在一处集中加载并按类型校验，缺失或非法时启动失败，报键名和原因，不输出值",
   "level": "hard",
   "check": "semi",
   "how": "配置加载代码有单元测试覆盖缺失和非法值的情况；评审时搜索业务代码里散落的读取环境变量的地方",
   "applies": [
    "kind:service",
    "kind:cli"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-022"
  },
  {
   "id": "COD-023",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "每个配置项写明类型、单位、取值范围、默认值、是否必填、是否敏感；写清多个来源的覆盖顺序和是否支持热更新",
   "level": "soft",
   "check": "semi",
   "how": "用配置结构定义（schema）生成说明文档和校验代码；评审新增配置项",
   "applies": [
    "kind:service",
    "kind:cli"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-023"
  },
  {
   "id": "COD-024",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "复杂度阈值沿用各语言工具的默认值，只对新增和改动的代码执行；存量代码用基线记录，不一次性全仓阻断",
   "level": "soft",
   "check": "auto",
   "how": "在 CI 里只对改动部分运行复杂度检查，超出时要求说明或简化（工具见 10–15）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-024"
  },
  {
   "id": "COD-025",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "不设跨语言统一的函数或文件行数上限；不为凑行数机械拆出没有意义的小函数",
   "level": "soft",
   "check": "manual",
   "how": "评审时看函数是否只做一件事，而不是只看行数",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-025"
  },
  {
   "id": "COD-026",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "跨系统传递的时间点用 RFC 3339 格式并带时区偏移；存储和比较用 UTC",
   "level": "hard",
   "check": "auto",
   "how": "接口结构定义里时间字段声明为 date-time 格式并做校验；评审时检查不带偏移的时间字符串",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-026"
  },
  {
   "id": "COD-027",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "有本地时间含义的数据（排班、每天当地 9 点、未来某天的预约）另存 IANA 时区名，不只存一个偏移量",
   "level": "hard",
   "check": "semi",
   "how": "评审这类字段是否同时保存了时区名；时区计算用标准库或成熟时间库，不自己算夏令时",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-027"
  },
  {
   "id": "COD-028",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "时长和超时写明单位；计算耗时用单调时钟，不用系统墙上时间相减",
   "level": "hard",
   "check": "semi",
   "how": "评审耗时计算用的时钟接口；部分语言有 lint 可提示（工具见 10–15）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-028"
  },
  {
   "id": "COD-029",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "金额不用二进制浮点数计算和存储；用十进制类型或最小货币单位的整数，同时带 ISO 4217 币种代码和明确的舍入规则",
   "level": "hard",
   "check": "semi",
   "how": "评审金额字段和计算代码的类型；接口结构定义里金额字段不用 number/float",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-029"
  },
  {
   "id": "COD-030",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "浮点数比较按业务的误差要求判断，不用 `==` 直接比较，也不套一个全局通用的误差值",
   "level": "soft",
   "check": "semi",
   "how": "开启各语言的浮点相等比较提示（如 Rust Clippy `float_cmp`），评审确认误差来源",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-030"
  },
  {
   "id": "COD-031",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "源文件和交换的文本数据默认用 UTF-8；截断、计长度、比较时按字符处理，不按字节",
   "level": "hard",
   "check": "auto",
   "how": "在 `.editorconfig` 里设置 `charset = utf-8`，CI 检查文件编码；评审字符串截断和长度校验的实现",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-031"
  },
  {
   "id": "COD-032",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "在 JSON 里传递可能超过 2^53 的整数（如长整型编号）时用字符串；接口里写清整数范围，以及「字段为空」和「字段不存在」是否有区别",
   "level": "hard",
   "check": "semi",
   "how": "评审接口结构定义里的整数字段类型和范围；用超大值做契约测试",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-032"
  },
  {
   "id": "COD-033",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "每个网络调用和外部调用都设置超时或截止时间，并把剩余时间传给下游",
   "level": "hard",
   "check": "semi",
   "how": "评审外部调用的客户端配置；搜索没有设置超时的客户端构造（部分语言有 lint 可提示）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-033"
  },
  {
   "id": "COD-034",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "重试只针对临时故障，必须有次数上限和带随机抖动的指数退避；非幂等的写操作没有幂等设计时不自动重试",
   "level": "hard",
   "check": "semi",
   "how": "评审重试配置（上限、退避、可重试的错误类型）；用现成的重试库或客户端自带的重试，不手写循环",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-034"
  },
  {
   "id": "COD-035",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "启动的每个后台任务、线程或协程都有明确的负责方和停止方式（取消或截止时间），退出时回收资源",
   "level": "soft",
   "check": "semi",
   "how": "评审任务的启动和停止路径；测试里检查泄漏（如 Go 的 goleak）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-035"
  },
  {
   "id": "COD-036",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "多个执行单元共享的可变数据必须有明确的同步方式；支持竞态检测的语言在测试中开启它",
   "level": "hard",
   "check": "semi",
   "how": "测试时开启竞态检测（如 Go 的 `-race`、C/C++/Rust 的 ThreadSanitizer）；评审共享状态的访问方式",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-036"
  },
  {
   "id": "COD-037",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "时间、金额、JSON、UUID、加密、重试这类有大量边角情况的功能，用标准库或成熟的库，不自己写；新增依赖写明理由",
   "level": "soft",
   "check": "manual",
   "how": "评审依赖清单的变化和新增的工具函数",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-037"
  },
  {
   "id": "COD-038",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "每种语言只用一个格式器，配置文件入库，CI 用检查模式验证；评审不再讨论空白和导入顺序",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行格式器的检查模式（如 `--check`），有差异即失败（工具见 10–15）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-038"
  },
  {
   "id": "COD-039",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "在代码里关闭 lint 时必须指明具体规则并写理由，不允许整文件或不指明规则地关闭",
   "level": "hard",
   "check": "auto",
   "how": "用 lint 自带的检查强制执行（如 ESLint 的未使用禁用指令报告和禁用注释说明、Ruff `PGH004`、golangci-lint 的 nolintlint 要求写说明）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-039"
  },
  {
   "id": "COD-040",
   "topic": "02",
   "topicTitle": "通用编码规范",
   "title": "长期的 lint 例外（基线文件、整条规则关闭）登记理由、负责人和到期日，到期复查",
   "level": "soft",
   "check": "semi",
   "how": "脚本检查例外清单里每项都有负责人和到期日，到期时提醒",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/coding/#cod-040"
  },
  {
   "id": "FLW-001",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "主干必须设为受保护分支：禁止直接推送、强推和删除，改动只能经 PR 合入",
   "level": "hard",
   "check": "auto",
   "how": "检查平台的分支保护或规则集设置；GitHub 上同时勾选「不允许绕过」，让管理员也受约束",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-001"
  },
  {
   "id": "FLW-002",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "默认用短分支的主干开发；长期分支只留给要并行维护的发布线",
   "level": "soft",
   "check": "manual",
   "how": "评审分支模型时看是否有长期存在、定期同步的功能或 develop 分支",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-002"
  },
  {
   "id": "FLW-003",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "分支名用「类型/任务编号-简述」，如 feat/PROJ-142-export-report",
   "level": "soft",
   "check": "auto",
   "how": "推送钩子或 CI 用正则检查分支名，不符合时提示",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-003"
  },
  {
   "id": "FLW-004",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "功能分支上可以多次小提交；默认用 squash 合并，要保留提交历史的仓库改用 rebase 或 merge 并写进贡献说明",
   "level": "soft",
   "check": "auto",
   "how": "检查平台允许的合并方式设置",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-004"
  },
  {
   "id": "FLW-005",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "用 squash 合并的仓库，最终提交信息（PR 标题）必须符合 Conventional Commits，破坏性变更必须明确标注",
   "level": "hard",
   "check": "auto",
   "how": "CI 对 PR 标题跑 commitlint；破坏性变更检查标题带 `!` 或正文有 `BREAKING CHANGE:`",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-005"
  },
  {
   "id": "FLW-006",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "提交信息只用来生成变更日志草稿；面向用户的部分由人改写后再发布",
   "level": "soft",
   "check": "manual",
   "how": "发布评审时检查变更日志里面向用户的条目是否经过改写，而不是原样的提交标题",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-006"
  },
  {
   "id": "FLW-007",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "暂存时只 git add 明确的路径，不用 git add -A 或 git add .；提交前核对暂存清单和未跟踪文件",
   "level": "soft",
   "check": "semi",
   "how": "提交前看 `git status --short` 和 `git diff --cached --name-only`；Agent 的权限设置里禁止 `git add -A` / `git add .`",
   "applies": [
    "all",
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-007"
  },
  {
   "id": "FLW-008",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "必过检查必须在服务端 CI 上运行并作为合并条件；本地钩子只做提前反馈，不能替代 CI",
   "level": "hard",
   "check": "auto",
   "how": "分支保护里配置必过检查；核对每项本地钩子检查在 CI 里都有对应项",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-008"
  },
  {
   "id": "FLW-009",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "是否强制签名提交按审计要求决定；开启前先实测机器人提交和 squash 合并",
   "level": "soft",
   "check": "manual",
   "how": "在测试仓库开启签名要求，跑一遍机器人提交、squash 合并、本地合并，确认都能通过",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-009"
  },
  {
   "id": "FLW-010",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "一个可独立评审、可独立验收的改动一个 PR；大功能拆成多个 PR",
   "level": "soft",
   "check": "semi",
   "how": "CI 统计改动行数和文件数（排除生成代码、锁文件、纯格式化），超过参考值时提示作者说明为什么不拆",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-010"
  },
  {
   "id": "FLW-011",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "PR 关联任务编号和验收标准；错别字、例行依赖更新这类小改动可以免关联",
   "level": "soft",
   "check": "semi",
   "how": "CI 检查 PR 描述里有任务链接，或带有约定的豁免标签",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-011"
  },
  {
   "id": "FLW-012",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "PR 描述写清目标、改动、不在范围内的内容、风险、验证结果、没验证的项、回滚或修复办法",
   "level": "soft",
   "check": "auto",
   "how": "用平台的 PR 模板固定这些栏目，CI 检查栏目非空",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-012"
  },
  {
   "id": "FLW-013",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "验证结果必须如实记录：每项检查标「通过、失败、未运行、已跳过」并附命令和证据位置；没跑的写原因，写了原因也不能豁免必过检查",
   "level": "hard",
   "check": "semi",
   "how": "评审时把 PR 里写的结果和 CI 在最终提交上的记录对照；抽查命令和证据链接",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-013"
  },
  {
   "id": "FLW-014",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "还没准备好评审的 PR 用草稿状态（Draft）打开",
   "level": "soft",
   "check": "manual",
   "how": "评审者只处理已标记为可评审的 PR",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-014"
  },
  {
   "id": "FLW-015",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "合并到主干前必须有至少一名非作者批准；平台不能强制审批时，把合并权限只给评审人并人工核对",
   "level": "hard",
   "check": "semi",
   "how": "支持的平台在分支保护里设必需批准数并禁止作者自批；GitLab 免费版等不支持强制审批的，在受保护分支里把「允许合并」限定给维护者角色，定期抽查合并记录",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-015"
  },
  {
   "id": "FLW-016",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "人工评审看需求是否达成、数据和状态语义、接口兼容、异常流程和回滚可行性；格式、lint、类型检查交给工具",
   "level": "soft",
   "check": "manual",
   "how": "评审清单列出这些看点；CI 里有对应的格式和 lint 检查",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-016"
  },
  {
   "id": "FLW-017",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "AI 评审的批准不能作为合并的唯一批准；必需批准里至少有一名承担责任的人",
   "level": "hard",
   "check": "semi",
   "how": "检查 AI 评审工具的「允许批准」设置；开启时，分支保护的必需批准数要多出一名人工，或对关键路径要求 CODEOWNERS 里的人批准",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-017"
  },
  {
   "id": "FLW-018",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "阻断性评审意见必须解决后才能合并；有新提交时旧批准失效，受影响的检查重跑",
   "level": "hard",
   "check": "auto",
   "how": "分支保护里开启「合并前需解决所有讨论」和「新提交后撤销旧批准」",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-018"
  },
  {
   "id": "FLW-019",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "评审及时给出首次有效回应，并把时效作为团队目标统计；超时不能自动批准",
   "level": "soft",
   "check": "semi",
   "how": "统计从「可评审」到首次有效评审的时间",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-019"
  },
  {
   "id": "FLW-020",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "用 CODEOWNERS 指定路径负责人，并在分支保护里要求负责人批准；CODEOWNERS、CI 配置、发布配置本身也要有负责人",
   "level": "hard",
   "check": "auto",
   "how": "检查分支保护开启了「需要代码负责人批准」；检查 CODEOWNERS 覆盖自身、CI 目录和发布配置",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-020"
  },
  {
   "id": "FLW-021",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "涉及权限、凭据、计费、客户数据、数据库迁移、发布和门禁配置的改动，必须由对应负责人额外批准",
   "level": "hard",
   "check": "auto",
   "how": "把这些路径写进 CODEOWNERS 并要求负责人批准；无法按路径识别的，用风险标签触发额外审批",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-021"
  },
  {
   "id": "FLW-022",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "必过检查清单写在仓库里，检查名唯一且固定，并限定由哪个 CI 应用上报",
   "level": "hard",
   "check": "auto",
   "how": "脚本比对仓库声明的检查清单和分支保护里的必过检查；GitHub 上为每项必过检查指定来源应用",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-022"
  },
  {
   "id": "FLW-023",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "必过检查的结果必须对应待合并的最新提交；用接口合并时带上 head 提交编号作为条件",
   "level": "hard",
   "check": "auto",
   "how": "分支保护要求分支与主干同步或启用合并队列；合并接口传入期望的 head SHA，不一致就失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-023"
  },
  {
   "id": "FLW-024",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "CI 的检查逻辑写在仓库内的脚本里，平台 YAML 只负责触发、权限和运行环境；本地能跑同一条命令",
   "level": "soft",
   "check": "semi",
   "how": "评审时看 CI 配置是否只调用 `scripts/ci/*` 这类脚本，没有大段内联逻辑",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-024"
  },
  {
   "id": "FLW-025",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "CI 分层：PR 上跑决定能否合并的快速检查，合并后或定时跑耗时长的回归，发版时再跑发布门禁",
   "level": "soft",
   "check": "manual",
   "how": "评审 CI 配置时核对每层的触发条件和是否为必过",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-025"
  },
  {
   "id": "FLW-026",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "启用合并队列（或合并列车）时，必过检查必须在队列生成的合并候选上运行",
   "level": "hard",
   "check": "auto",
   "how": "GitHub 的工作流要监听 `merge_group` 事件；第三方 CI 要对 `gh-readonly-queue/` 开头的分支运行；GitLab 合并列车按官方配置开启合并结果流水线",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-026"
  },
  {
   "id": "FLW-027",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "CI 缓存只放能重新生成的依赖和中间结果，不放密钥，不当正式制品用；要传给后续步骤的产物用制品（artifact）机制",
   "level": "hard",
   "check": "semi",
   "how": "评审 CI 配置的缓存路径，确认不含凭据文件和发布产物；缓存键包含锁文件哈希和运行环境版本",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-027"
  },
  {
   "id": "FLW-028",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "同一分支上过期的 PR 流水线可以自动取消；发布、数据库迁移、共享集成环境的任务要串行，且取消不能留下半成品",
   "level": "soft",
   "check": "auto",
   "how": "检查 CI 配置的并发设置：GitHub 用 `concurrency`，GitLab 用 `workflow: rules` 去重和资源组",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-028"
  },
  {
   "id": "FLW-029",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "有对外接口、SDK 或库的项目用语义化版本（SemVer）；按时间交付、没有公共接口的产品可以用日历版本（CalVer）",
   "level": "soft",
   "check": "manual",
   "how": "评审版本策略时看项目是否有被别人依赖的接口",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-029"
  },
  {
   "id": "FLW-030",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "每次发布绑定固定的源提交、版本号和制品摘要；发布记录写明兼容性变化、批准人和回滚或修复办法",
   "level": "soft",
   "check": "auto",
   "how": "发布流水线生成发布清单（版本、源提交、构建记录、制品名、sha256、渠道、说明、回滚办法），缺项时失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-030"
  },
  {
   "id": "FLW-031",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "已发布的版本不得改内容：不重写或删除已发布的 tag，不覆盖同名制品；有问题发新版本",
   "level": "hard",
   "check": "auto",
   "how": "平台上开启 tag 保护；制品库关闭同版本覆盖",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-031"
  },
  {
   "id": "FLW-032",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "回滚代码用 git revert 生成新提交，不强推主干；回滚合并提交时明确指定主线",
   "level": "soft",
   "check": "semi",
   "how": "主干禁止强推（FLW-001）；评审回滚 PR 时看是否为 revert 提交",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-032"
  },
  {
   "id": "FLW-033",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "依赖更新用机器人自动开 PR 并跑必过检查，按更新级别分级处理；默认不自动合并主版本升级",
   "level": "soft",
   "check": "auto",
   "how": "检查依赖机器人的配置：主版本更新不开自动合并；对配置文件跑官方的配置校验",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-033"
  },
  {
   "id": "FLW-034",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "一个仓库只用一个本地 Git 钩子管理工具，钩子里跑的命令和 CI 一致",
   "level": "soft",
   "check": "semi",
   "how": "检查仓库里是否同时存在多个钩子配置（如 lefthook.yml 和 .pre-commit-config.yaml）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-034"
  },
  {
   "id": "FLW-035",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "仓库模板的更新走版本化的 PR；已有项目按版本主动升级，不悄悄改动它们的规则",
   "level": "soft",
   "check": "semi",
   "how": "模板仓库打版本 tag；项目里记录所用模板版本，CI 提示有新版本",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-035"
  },
  {
   "id": "FLW-036",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "issue 用统一的核心字段：负责人、优先级、状态、验收标准、风险；优先级和工作量分开记",
   "level": "soft",
   "check": "semi",
   "how": "issue 模板固定这些字段；定期统计缺字段的 issue",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-036"
  },
  {
   "id": "FLW-037",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "流程要求能由平台或 CI 强制的就配置强制；平台不支持的能力要写明替代做法，不能当作已强制",
   "level": "soft",
   "check": "manual",
   "how": "列出贡献说明里的每条「必须」，逐项对应到分支保护、CI 检查或人工审查点；对不上的标出来",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-037"
  },
  {
   "id": "FLW-038",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "要在内网或断网环境交付的项目，交付前必须在断网条件下跑通构建、CI 和发布",
   "level": "hard",
   "check": "auto",
   "how": "定期在禁止外网的 runner 上跑完整流水线，依赖只从内部镜像拉取",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-038"
  },
  {
   "id": "FLW-039",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "按变更边界选单仓或多仓：经常一起改、一起发布的放一个仓库；权限、生命周期、客户隔离不同的分开",
   "level": "soft",
   "check": "manual",
   "how": "架构评审时看跨仓的原子改动频率和权限边界",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-039"
  },
  {
   "id": "FLW-040",
   "topic": "03",
   "topicTitle": "工程流程与协作",
   "title": "并行任务要改同一个高争用文件（迁移、锁文件、接口契约、CI 配置）时，先约定负责人和合并顺序，或先合入契约 PR",
   "level": "soft",
   "check": "manual",
   "how": "开工前在任务里写明会改的高争用文件；评审时看是否有两个进行中的 PR 改了同一个",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/workflow/#flw-040"
  },
  {
   "id": "TST-001",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "不规定统一的单元、集成、E2E 数量比例；按风险和系统边界决定每类项目需要哪些测试",
   "level": "soft",
   "check": "manual",
   "how": "评审仓库的测试策略说明，看是否按项目类型写了各类测试的用途，而不是只写比例",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-001"
  },
  {
   "id": "TST-002",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "仓库写明测试策略：项目类型、改动风险分级规则、每级必跑的检查、证据保留多久",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查策略文件存在且字段齐全（可用 JSON Schema 校验）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-002"
  },
  {
   "id": "TST-003",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "改动按 R0–R3 分级决定必跑检查和审批；改动者只能提高级别，不能自己降级，拿不准按高一级",
   "level": "soft",
   "check": "semi",
   "how": "按改动路径（迁移、权限、策略、锁文件）和接口破坏性差异自动给出最低级别；评审确认或上调",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-003"
  },
  {
   "id": "TST-004",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "禁止提交聚焦运行（only / focus）、无期限跳过、空断言或恒真断言、吞掉异常、强制成功的测试",
   "level": "hard",
   "check": "auto",
   "how": "用各语言测试框架的 lint 规则检查聚焦与跳过（规则见 10–15）；跳过必须带工单号和到期日，CI 检查",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-004"
  },
  {
   "id": "TST-005",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "修复缺陷时补一个在修复前会失败的回归测试；无法自动化的，附上可复现步骤",
   "level": "soft",
   "check": "semi",
   "how": "评审时看修复 PR 是否带回归测试；可在 CI 里把新测试放到修复前的提交上跑一次，确认会失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-005"
  },
  {
   "id": "TST-006",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "改动有语义的生产逻辑时，带上对应的行为测试；纯格式或重命名可以例外",
   "level": "soft",
   "check": "semi",
   "how": "CI 发现生产代码有改动而测试无改动时提示，评审确认例外理由",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-006"
  },
  {
   "id": "TST-007",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "核心路径的测试覆盖正常、边界、无效输入和失败恢复，不只测顺利路径",
   "level": "soft",
   "check": "manual",
   "how": "评审时对照测试清单，看每条核心路径是否有边界和失败用例",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-007"
  },
  {
   "id": "TST-008",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "削弱断言、删除测试、批量更新快照或截图基线、调低阈值、修改测试配置的改动，必须在 PR 里说明理由并由测试负责人批准",
   "level": "hard",
   "check": "semi",
   "how": "把测试配置、基线目录、质量策略文件写进 CODEOWNERS；CI 识别删除测试和大批量基线更新并打标",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-008"
  },
  {
   "id": "TST-009",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "高风险模块用定向变异测试或人为注入缺陷，检验测试能否发现错误；不要求每个 PR 全量变异或全部杀死",
   "level": "soft",
   "check": "semi",
   "how": "对 R3 模块定期跑变异测试，存活的变异体逐个说明（等价变异可以不杀）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-009"
  },
  {
   "id": "TST-010",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "涉及数据库、消息队列、缓存、文件存储语义的改动，必须有接真实同款组件的集成测试；不用别的引擎冒充（如用 SQLite 代替 PostgreSQL）",
   "level": "hard",
   "check": "semi",
   "how": "CI 里有标记为集成测试的任务，并能看到启动的组件镜像；评审时检查是否用其他引擎代替",
   "applies": [
    "kind:service",
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-010"
  },
  {
   "id": "TST-011",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "测试数据相互隔离：每次运行用独立的库、schema 或实例，跑完不影响下一次，能并行；只有能证明所有读写都在同一事务内时才用事务回滚做清理",
   "level": "soft",
   "check": "semi",
   "how": "打乱顺序并行跑两遍测试，结果应一致",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-011"
  },
  {
   "id": "TST-012",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "测试不得连接生产环境的写接口或生产数据库",
   "level": "hard",
   "check": "auto",
   "how": "测试环境的网络策略只放行测试依赖；配置检查禁止测试读取生产连接串",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-012"
  },
  {
   "id": "TST-013",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "容器化的测试依赖固定镜像摘要，用健康检查等到服务就绪；不把「容器已启动」当成就绪",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查测试用镜像引用带摘要；集成测试框架配置等待策略",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-013"
  },
  {
   "id": "TST-014",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "时间、随机数、时区、语言环境可注入可固定；随机或属性测试失败时记录种子和缩减后的最小样例",
   "level": "soft",
   "check": "semi",
   "how": "评审时看相关代码是否通过注入的时钟和随机源取值；CI 报告里能看到失败用例的种子",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-014"
  },
  {
   "id": "TST-015",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "测试数据默认用合成数据；用真实数据必须先脱敏并经批准；录制回放先替换令牌、时间戳和用户标识；密钥和个人信息不得进入测试制品",
   "level": "hard",
   "check": "semi",
   "how": "对测试数据、录制文件和 CI 制品跑密钥与敏感信息扫描；fixture 标注来源",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-015"
  },
  {
   "id": "TST-016",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "数据库迁移在旧版本的脱敏样本库上验证：升级、核对结构、读写行为；不可逆的迁移不声称可以自动回滚",
   "level": "soft",
   "check": "semi",
   "how": "CI 用上一个发布版本的样本库跑迁移和冒烟测试",
   "applies": [
    "kind:service",
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-016"
  },
  {
   "id": "TST-017",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "公开接口的改动必须通过 schema 校验和向后兼容检查；破坏性改动要显式标注并走 R3",
   "level": "hard",
   "check": "auto",
   "how": "CI 对接口描述文件跑 lint 和破坏性差异检测（工具见 16）",
   "applies": [
    "kind:service",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-017"
  },
  {
   "id": "TST-018",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "有接口描述文件时，用基于 schema 的属性测试检查实现与描述一致；「返回 200」不算契约通过",
   "level": "soft",
   "check": "auto",
   "how": "CI 对运行中的测试实例跑基于 schema 的测试，检查状态码、响应结构和状态流转",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-018"
  },
  {
   "id": "TST-019",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "有跨团队或独立发布的消费方时，用消费者驱动的契约测试；同仓同团队的内部模块不强上契约中心",
   "level": "soft",
   "check": "semi",
   "how": "评审时看接口的消费方清单：独立发布的消费方是否都有契约，并在提供方 CI 里验证",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-019"
  },
  {
   "id": "TST-020",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "UI 测试不得用固定时长等待（sleep）来等页面状态",
   "level": "hard",
   "check": "auto",
   "how": "用 E2E 框架的 lint 规则禁止固定等待（如 Playwright 的 waitForTimeout）",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-020"
  },
  {
   "id": "TST-021",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "UI 测试用面向用户的定位方式（角色、可见文本、标签）和会自动重试的断言，断言用户看得到的结果；不只断言 CSS 类名或内部状态",
   "level": "soft",
   "check": "semi",
   "how": "lint 提示基于 CSS 或 XPath 的定位；评审时看断言对象",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-021"
  },
  {
   "id": "TST-022",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "使用视觉回归时，截图基线只在固定的环境（镜像、浏览器、字体）里生成和更新，基线变化在 PR 里评审",
   "level": "hard",
   "check": "semi",
   "how": "基线只由 CI 的指定任务生成；基线目录写进 CODEOWNERS",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-022"
  },
  {
   "id": "TST-023",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "视觉回归只覆盖关键页面和关键状态，不把全站全页截图作为每个 PR 的门槛",
   "level": "soft",
   "check": "manual",
   "how": "评审视觉测试清单，看每张截图对应的页面或状态是否必要",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-023"
  },
  {
   "id": "TST-024",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "重试后才通过的测试必须标为不稳定（flaky），保留每次尝试的结果；不得报告为一次通过",
   "level": "hard",
   "check": "auto",
   "how": "测试报告保留每次尝试；CI 汇总时单列「重试后通过」",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-024"
  },
  {
   "id": "TST-025",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "隔离不稳定测试时登记负责人、跟踪工单和到期日；到期未修恢复阻断；R3 路径的测试不长期隔离",
   "level": "soft",
   "check": "auto",
   "how": "隔离清单放在仓库里，CI 检查必填字段和到期日",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-025"
  },
  {
   "id": "TST-026",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "定期统计不稳定测试的比例和排名，按环境、并行度、时区分开看，优先修影响最大的",
   "level": "soft",
   "check": "auto",
   "how": "从 CI 历史报告汇总「重试后通过」的运行占比，每个周期出一份排名",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-026"
  },
  {
   "id": "TST-027",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "覆盖率只作信号，不设全局硬门槛，不单独决定能否合并；改动部分的覆盖率可以作参考",
   "level": "soft",
   "check": "auto",
   "how": "CI 报告整体和改动部分的覆盖率；核心模块覆盖下降时要求在 PR 里说明",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-027"
  },
  {
   "id": "TST-028",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "发布阻断的检查必须在可信 CI 上对最终提交执行；本地结果、截图和口头报告不算通过",
   "level": "hard",
   "check": "auto",
   "how": "分支保护只认指定 CI 上报的检查结果（见 03）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-028"
  },
  {
   "id": "TST-029",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "每项检查的结果用「未运行、通过、失败、已跳过」之一记录；已跳过要写原因，条件型检查要写明为什么不适用",
   "level": "hard",
   "check": "auto",
   "how": "CI 汇总报告时检查每项必跑检查都有状态；已跳过的项缺少原因时报错",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-029"
  },
  {
   "id": "TST-030",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "验收记录至少绑定完整提交 SHA、CI 运行编号和原始测试报告；R3 改动再加锁文件和运行环境摘要、制品摘要",
   "level": "hard",
   "check": "auto",
   "how": "CI 生成证据清单并校验必填字段；原始报告（如 JUnit XML）随运行保存",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-030"
  },
  {
   "id": "TST-031",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "源码、锁文件、测试代码、运行配置或测试策略变化后，相关的验收结果失效要重跑；合并前对最终合并结果再验证",
   "level": "hard",
   "check": "auto",
   "how": "用分支保护的「合并前必须与目标分支同步」或合并队列（配置见 03）；用合并队列时 CI 要响应合并组事件",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-031"
  },
  {
   "id": "TST-032",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "失败时保留能复现的材料：UI 留 trace 和截图，CLI 留命令、退出码和输出，数据库留语句和状态",
   "level": "soft",
   "check": "auto",
   "how": "CI 在失败时自动上传对应制品，并检查制品是否存在",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-032"
  },
  {
   "id": "TST-033",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "调用大模型的功能要有一层可阻断发布的确定性检查：输出结构、工具参数、权限、执行后的真实状态；语义质量的评测方法见 24",
   "level": "hard",
   "check": "semi",
   "how": "测试直接检查系统状态、文件和数据库，而不是模型或工具返回的「已完成」",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-033"
  },
  {
   "id": "TST-034",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "要交付安装的产品，每个发布构建在干净环境里安装、启动、迁移或升级并做冒烟测试，测的是最终制品而不是源码",
   "level": "soft",
   "check": "auto",
   "how": "CI 在全新容器或虚拟机里安装发布制品，检查退出码和健康检查",
   "applies": [
    "kind:service",
    "kind:cli",
    "platform:desktop",
    "platform:mobile",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-034"
  },
  {
   "id": "TST-035",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "要求断网部署的产品，在无外网的环境里跑一遍安装和测试；浏览器、镜像、字体、模型等依赖全部预置",
   "level": "hard",
   "check": "auto",
   "how": "CI 里有禁用外网的任务跑完整安装和冒烟测试",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-035"
  },
  {
   "id": "TST-036",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "对超时、断线、重复投递、部分写入、依赖不可用等故障做注入测试，验证重试、幂等和恢复；R3 改动必须覆盖",
   "level": "soft",
   "check": "semi",
   "how": "集成测试里用故障代理或测试替身制造故障；评审 R3 改动时检查对应用例",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-036"
  },
  {
   "id": "TST-037",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "性能要求写成可测的场景和指标，在固定环境里测并和基线比较；不在共享 CI 机器上用绝对耗时做门槛",
   "level": "soft",
   "check": "manual",
   "how": "评审性能测试方案，看场景、指标、环境和基线是否写明",
   "applies": [
    "kind:service",
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-037"
  },
  {
   "id": "TST-038",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "变异测试全量、长时模糊测试、全量视觉回归、压测放到定时任务或 R3 改动上，不作为每个 PR 的同步门槛",
   "level": "soft",
   "check": "auto",
   "how": "CI 分快检和重检两类任务，统计每类耗时",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-038"
  },
  {
   "id": "TST-039",
   "topic": "04",
   "topicTitle": "测试与质量",
   "title": "用现成的测试工具，不自研浏览器驱动、截图比对、HTTP 模拟、容器管理、变异引擎或压测器",
   "level": "soft",
   "check": "manual",
   "how": "评审新增的测试基础设施代码，看是否已有成熟工具能做",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/testing/#tst-039"
  },
  {
   "id": "DOC-001",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "每个交付的仓库根目录必须有 README，写明用途、前置条件、启动命令、测试命令、文档入口和支持边界",
   "level": "hard",
   "check": "semi",
   "how": "CI 检查 README.md 存在且包含这几类小节；内容是否准确靠评审，启动和测试命令能自动跑的放进示例测试（见 DOC-016）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-001"
  },
  {
   "id": "DOC-002",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "读者任务明显不同的内容，分成教程、操作指南、参考、解释四类，分开写、分开导航",
   "level": "soft",
   "check": "manual",
   "how": "评审时看新文档属于哪一类，是否把教程、参考和原理解释混在一篇里",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-002"
  },
  {
   "id": "DOC-003",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "README、CONTRIBUTING、CHANGELOG 放仓库根目录，其余文档放 docs/；按需建目录，不提交空目录和空模板",
   "level": "soft",
   "check": "semi",
   "how": "脚本列出 docs/ 下的空文件、只有标题的页面和空目录，评审时确认",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-003"
  },
  {
   "id": "DOC-004",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "文档放在代码仓库里和代码一起评审；跨项目共用的规范按版本号或提交编号引用，不整份复制进每个项目",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查是否有从别处整份复制过来的规范文本，引用外部规范时是否锁定了版本",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-004"
  },
  {
   "id": "DOC-005",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "文档文件名用小写 ASCII 加连字符（kebab-case）；被外部引用的标题要显式设定锚点，不依赖中文标题自动生成",
   "level": "soft",
   "check": "auto",
   "how": "脚本检查 docs/ 下文件名；构建后用链接检查验证锚点",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-005"
  },
  {
   "id": "DOC-006",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "影响多个模块、难以撤销或有明显取舍的技术决定，写一篇 ADR，包含背景、决定、考虑过的备选、后果和状态",
   "level": "soft",
   "check": "manual",
   "how": "评审跨模块、引入新基础设施或改变公共约定的 PR 时，检查是否有对应的 ADR 链接",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-006"
  },
  {
   "id": "DOC-007",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "ADR 放在 docs/adr/，按递增编号命名（如 0007-use-postgres-queue.md）；已接受的 ADR 不改结论，要推翻就新写一篇并在两边互相链接",
   "level": "soft",
   "check": "auto",
   "how": "脚本检查 docs/adr/ 下的文件名格式和编号不重复；评审时检查对已接受 ADR 的修改只涉及状态和链接",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-007"
  },
  {
   "id": "DOC-008",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "每篇 Markdown 只有一个一级标题，标题逐级递进，代码块标明语言",
   "level": "hard",
   "check": "auto",
   "how": "markdownlint 开启 MD001（标题逐级）、MD025（单个一级标题）、MD040（代码块标语言）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-008"
  },
  {
   "id": "DOC-009",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "链接文字写明去向，不用「这里」「点击这里」「链接」；图片必须有替代文本",
   "level": "hard",
   "check": "auto",
   "how": "markdownlint 开启 MD045（图片要有替代文本）和 MD059（链接文字要有描述性），并在 MD059 的禁用词里补上中文的「这里」「点击这里」「此处」「链接」",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-009"
  },
  {
   "id": "DOC-010",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "图承载关键信息时，在图下用几句话写出结论，不只写「架构图」这样的替代文本",
   "level": "soft",
   "check": "manual",
   "how": "评审含架构图、流程图的文档时，检查图下是否有文字说明",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-010"
  },
  {
   "id": "DOC-011",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "中文句子用全角标点；中文与英文、数字之间是否留空格，按团队定下的风格和例外表执行，并用一个工具在 CI 里检查",
   "level": "soft",
   "check": "auto",
   "how": "CI 跑 AutoCorrect（或 zhlint）的检查模式，例外写进工具的配置文件",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-011"
  },
  {
   "id": "DOC-012",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "中英混排的空格属于团队风格，不要写成国家标准的强制要求；需要引国标时引 GB/T 15834（标点）和 GB/T 15835（数字）",
   "level": "soft",
   "check": "manual",
   "how": "评审风格指南时检查是否把团队约定标成了「国标要求」",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-012"
  },
  {
   "id": "DOC-013",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "中文排版检查只选一个工具负责自动修复，其他工具只报告；CI 里只检查、不写回源文件",
   "level": "soft",
   "check": "semi",
   "how": "检查 CI 配置里没有 `--fix` 类参数；检查仓库里是否同时启用了多个中文排版修复器",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-013"
  },
  {
   "id": "DOC-014",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "启用中文排版检查前，先建一组回归样例，覆盖中文、英文、数字、代码、链接、表格和界面字符串",
   "level": "soft",
   "check": "semi",
   "how": "样例集放在仓库里，升级检查工具或修改规则时先跑一遍，比对结果",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-014"
  },
  {
   "id": "DOC-015",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "产品名、术语、大小写和单位以仓库里版本化的术语表为准，新术语先进术语表再使用",
   "level": "soft",
   "check": "semi",
   "how": "用 Vale 的词表规则（accept/reject 列表）检查文档里的写法；术语表的修改随 PR 评审",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-015"
  },
  {
   "id": "DOC-016",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "句子写具体：一句一个主要动作，写出前提和可观察的结果；不用「显然」「简单」「只需」代替事实条件；句长只作提示，不设硬阈值",
   "level": "soft",
   "check": "semi",
   "how": "Vale 自定义规则把「显然」「简单地」「只需」「随便」等标为提示；句长规则只告警不阻断",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-016"
  },
  {
   "id": "DOC-017",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "操作指南和运行手册必须写明适用环境与版本、准确的命令和路径、输入和期望输出；示例完整可复制，省略处明确标出",
   "level": "hard",
   "check": "semi",
   "how": "评审操作类文档时逐项检查；能自动化的示例放进示例测试（见 DOC-019）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-017"
  },
  {
   "id": "DOC-018",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "文档里要求读者执行的关键命令，必须在声明支持的环境里实测过；没实测的标明「未验证」和环境",
   "level": "hard",
   "check": "semi",
   "how": "示例测试覆盖的命令视为已验证；其余命令评审时要求附实测记录，否则必须带「未验证」标记",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-018"
  },
  {
   "id": "DOC-019",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "能自动执行的示例（README 启动命令、代码片段、配置样例）放进 CI 定期跑",
   "level": "soft",
   "check": "auto",
   "how": "用各语言的文档测试能力或脚本抽取代码块执行；失败时阻断或开任务",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-019"
  },
  {
   "id": "DOC-020",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "改动命令行参数、配置项、权限、界面行为、部署方式或兼容性的 PR，必须在同一 PR 更新相关文档，或在 PR 描述里写 `docs-impact: none` 和理由",
   "level": "hard",
   "check": "semi",
   "how": "CI 按「代码路径 → 文档」映射表判断是否命中；命中但没改文档也没写影响声明时失败；声明是否合理由评审判断",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-020"
  },
  {
   "id": "DOC-021",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "重要文档目录（对外文档、运行手册、规范）在 CODEOWNERS 里指定负责人，并要求负责人批准；Agent 生成的文档同样要人审",
   "level": "soft",
   "check": "auto",
   "how": "检查 CODEOWNERS 覆盖这些目录，分支保护开启「需要代码负责人批准」",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-021"
  },
  {
   "id": "DOC-022",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "需要时效管理的文档（运行手册、对外操作指南、规范、版本支持矩阵）在 frontmatter 写 owner、status、reviewed_at、review_due、applies_to，并用 schema 校验",
   "level": "soft",
   "check": "auto",
   "how": "CI 用 JSON Schema 校验这些字段存在、日期格式正确",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-022"
  },
  {
   "id": "DOC-023",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "只有真的复核过内容才更新 reviewed_at；不拿文件最后修改时间或最近提交时间代替复核时间",
   "level": "soft",
   "check": "semi",
   "how": "PR 改动 reviewed_at 时，要求在描述里写明复核了什么；定期抽查",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-023"
  },
  {
   "id": "DOC-024",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "文档过了 review_due 只生成待复核任务，不阻断无关的 PR；生产应急用的运行手册过期可以设为发布门禁",
   "level": "soft",
   "check": "auto",
   "how": "定时任务扫描 frontmatter，过期的给 owner 开任务；只对标记为应急的运行手册接入发布检查",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-024"
  },
  {
   "id": "DOC-025",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "规范、选型和决策文档引用法规、版本、政策日期等外部事实时，写来源链接和核实日期；查不到的写「未核实」",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查外部事实是否带来源和日期；脚本可扫描文档里的版本号和日期附近是否有链接",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-025"
  },
  {
   "id": "DOC-026",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "文档构建失败、Markdown 结构检查失败、仓库内链接失效时必须阻断合并",
   "level": "hard",
   "check": "auto",
   "how": "把 markdownlint、`lychee --offline`（只查本地文件和内链）和文档站构建设为必过检查",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-026"
  },
  {
   "id": "DOC-027",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "外部链接检查不放进 PR 的必过检查，改为联网的定时任务；失败由人确认后再修复或登记豁免",
   "level": "soft",
   "check": "auto",
   "how": "定时任务跑 lychee 全量检查，设重试、限速和域名白名单；结果开任务而不是直接改文档",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-027"
  },
  {
   "id": "DOC-028",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "文档检查封装成仓库里的一个命令（如 `make docs-check` 或 `scripts/docs-check.sh`），本地、CI 和 Agent 都跑这一条",
   "level": "soft",
   "check": "semi",
   "how": "检查 CI 配置调用的是这个入口而不是散落的命令",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-028"
  },
  {
   "id": "DOC-029",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "架构图、流程图、时序图提交可编辑的文本源文件（.mmd、.d2、.puml）或白板源文件（.excalidraw）；PNG、SVG 只是导出物",
   "level": "soft",
   "check": "semi",
   "how": "脚本列出 docs/ 下没有同名源文件的图片，评审时确认",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-029"
  },
  {
   "id": "DOC-030",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "文本图在 CI 里用锁定版本的渲染器渲染，渲染失败即检查失败",
   "level": "hard",
   "check": "auto",
   "how": "CI 调用 Mermaid CLI、d2、PlantUML 的本地命令渲染所有图源文件，非零退出即失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-030"
  },
  {
   "id": "DOC-031",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "图写明范围和图例：箭头表示调用、事件还是数据流，标出信任边界和外部依赖；节点名和真实组件一致",
   "level": "soft",
   "check": "manual",
   "how": "评审图时逐项检查图例、边界和节点名",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-031"
  },
  {
   "id": "DOC-032",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "含内部或客户信息的图和文档，不得发到第三方在线渲染或编辑服务（公网 PlantUML 服务器、在线 Mermaid 编辑器、公共 Kroki 实例等），改用本地或自托管渲染",
   "level": "hard",
   "check": "semi",
   "how": "扫描文档、文档站配置和构建脚本里指向外部渲染服务的地址；在私有化环境用网络出口策略禁止",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-032"
  },
  {
   "id": "DOC-033",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "Mermaid 的 securityLevel 保持默认的 strict，不为了点击跳转改成 loose；必须渲染不可信来源的图时，用 sandbox 模式并配合 CSP",
   "level": "hard",
   "check": "auto",
   "how": "扫描文档站和应用里 `mermaid.initialize` 等配置，出现 `loose` 或 `antiscript` 时失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-033"
  },
  {
   "id": "DOC-034",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "文档站锁定依赖、能在本地复现构建；生产站点不用 alpha、beta 等预发布版本",
   "level": "soft",
   "check": "auto",
   "how": "CI 用锁文件安装并构建；检查依赖清单里文档站框架不是预发布版本",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-034"
  },
  {
   "id": "DOC-035",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "断网或私有化交付的文档站，字体、搜索索引、脚本和样式都必须打进交付包，不依赖 CDN、托管搜索或遥测；交付前做一次断网冒烟测试",
   "level": "hard",
   "check": "semi",
   "how": "在断网环境打开构建产物，抽查页面渲染、搜索和图片；构建产物里扫描外部域名",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-035"
  },
  {
   "id": "DOC-036",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "发布 llms.txt 时，只列已公开的页面并由站点导航自动生成；不列内部地址和私有内容，也不把它当作访问控制",
   "level": "hard",
   "check": "semi",
   "how": "发布 CI 从导航生成 llms.txt，扫描其中的域名和路径是否都在公开站点范围内",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-036"
  },
  {
   "id": "DOC-037",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "有对外发布版本的项目维护 CHANGELOG.md：每个已发布版本一节，写版本号和发布日期，最新的在最上面",
   "level": "hard",
   "check": "auto",
   "how": "发布 CI 检查即将打的版本号在 CHANGELOG.md 里有对应一节且带日期",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-037"
  },
  {
   "id": "DOC-038",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "变更日志面向用户写：改变了什么、影响谁、要做什么；草稿从提交信息生成，人工删掉内部改动后改写，不原样贴提交记录",
   "level": "soft",
   "check": "manual",
   "how": "发布评审时抽查条目是否用用户能懂的话写，是否还有内部任务编号和重构类条目",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-038"
  },
  {
   "id": "DOC-039",
   "topic": "05",
   "topicTitle": "文档与写作",
   "title": "含破坏性变更的版本，提供迁移指南，写清从哪个版本迁、要改什么、怎么验证、能否回退，并从变更日志链接过去",
   "level": "soft",
   "check": "semi",
   "how": "发布 CI 发现版本含破坏性变更标记时，检查变更日志里有迁移指南链接",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/docs/#doc-039"
  },
  {
   "id": "PRD-001",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "已批准的需求必须有唯一且不复用的编号、版本号、负责人和状态",
   "level": "hard",
   "check": "auto",
   "how": "用 JSON Schema 校验需求文件的头部字段，再用脚本检查编号全仓唯一、已删除的编号不被重新使用",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-001"
  },
  {
   "id": "PRD-002",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "开始实现前写清问题、目标用户、现状和希望带来的改变",
   "level": "soft",
   "check": "semi",
   "how": "模板固定这几栏，CI 检查非空；内容是否站得住由评审判断",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-002"
  },
  {
   "id": "PRD-003",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "写明本次范围内和范围外的内容，范围外要写具体的能力，不写「暂不考虑高级功能」",
   "level": "soft",
   "check": "semi",
   "how": "模板固定「范围内」「范围外」两栏，CI 检查非空；评审看范围外是否具体",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-003"
  },
  {
   "id": "PRD-004",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "把已确认的事实、未证实的假设、外部约束、未决问题分开写；推断出来的内容不写成已确认的需求",
   "level": "soft",
   "check": "semi",
   "how": "模板分栏；Agent 或评审标出没有出处的「已确认」内容",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-004"
  },
  {
   "id": "PRD-005",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "需求文档的详略按风险分级 R0–R3 决定，看影响面不看代码量",
   "level": "soft",
   "check": "manual",
   "how": "评审时对照词表的风险分级核对需求标的级别；改动者只能往高定",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-005"
  },
  {
   "id": "PRD-006",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "R3 需求在需求阶段必须完成风险检查：权限、资金、个人信息与数据出网、迁移与不可逆操作、断网要求、对不可信输入的处理；不适用的写明原因",
   "level": "hard",
   "check": "semi",
   "how": "需求模板带风险检查栏，CI 检查 R3 需求每一栏都填了结论或「不适用」及原因；由对应负责人确认",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-006"
  },
  {
   "id": "PRD-007",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "每条「必须」级需求至少关联一条有编号的验收标准（AC）",
   "level": "hard",
   "check": "auto",
   "how": "脚本检查每个 priority 为必须的需求都至少有一个 AC 指向它",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-007"
  },
  {
   "id": "PRD-008",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "验收标准用统一的一套字段：前置条件、操作、预期结果、边界与反例、验证方式、证据类型、验证人",
   "level": "soft",
   "check": "auto",
   "how": "用 JSON Schema 校验 AC 文件；字段名只用下表一套",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-008"
  },
  {
   "id": "PRD-009",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "「必须」级 AC 必须写明验证方式、证据类型和验证人",
   "level": "hard",
   "check": "auto",
   "how": "JSON Schema 对 scope_level 为必须的 AC 要求 verification 下三个字段非空",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-009"
  },
  {
   "id": "PRD-010",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "AC 写可观察的结果或可量化的判定，不只写「快」「友好」「完善」「和设计稿一样」；阈值由产品方按基线给出，不由开发或 Agent 编造",
   "level": "soft",
   "check": "semi",
   "how": "用词表扫描 AC 里的模糊词，命中的由评审改写；阈值字段要注明来源",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-010"
  },
  {
   "id": "PRD-011",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "涉及失败、拒绝、空结果、边界值的流程，AC 要覆盖这些情形，不只写顺利路径",
   "level": "soft",
   "check": "semi",
   "how": "Agent 或评审逐条检查 AC 的 boundary_cases；写「无」的要有原因",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-011"
  },
  {
   "id": "PRD-012",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "Gherkin 只在业务规则稳定、且场景会绑定步骤定义真正执行时使用；不强制所有需求写成 .feature",
   "level": "soft",
   "check": "manual",
   "how": "评审时看 .feature 文件是否都有对应的步骤定义并在 CI 里执行",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-012"
  },
  {
   "id": "PRD-013",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "规格质量通过、实现证据通过、业务验收通过是三层判断，前一层通过推不出后一层；CI 通过不等于 AC 通过",
   "level": "hard",
   "check": "semi",
   "how": "任务状态转换检查：进入「待验收」要求每条必须级 AC 都有证据；进入「已完成」要求有权人的验收记录",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-013"
  },
  {
   "id": "PRD-014",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "每条 AC 的验证结果只用「未运行、通过、失败、已跳过」记录；没有真正验证的写「未运行」并说明原因和后续负责人，不得写成通过",
   "level": "hard",
   "check": "auto",
   "how": "交付简报和验收矩阵用 schema 校验状态枚举；「未运行」「已跳过」缺原因时报错",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-014"
  },
  {
   "id": "PRD-015",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "必须级 AC 全部通过才算验收通过，不用总体通过率抵消未通过的必须项；例外由有权人记录",
   "level": "hard",
   "check": "auto",
   "how": "脚本根据验收矩阵计算：任一必须级 AC 不是「通过」且没有已批准的例外，就不允许转为「已完成」",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-015"
  },
  {
   "id": "PRD-016",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "提问前先查仓库、已有规格、历史决定和现有产品行为；能查到的不问",
   "level": "soft",
   "check": "manual",
   "how": "评审时抽查澄清问题，看答案是否本来就在仓库或文档里",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-016"
  },
  {
   "id": "PRD-017",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "每轮只问少数高影响、只有人能决定的问题；每个问题写明影响、可选项和推荐默认值，并把问答记进需求",
   "level": "soft",
   "check": "semi",
   "how": "脚本统计每轮问题数和字段是否齐全；问题质量由评审判断",
   "applies": [
    "all",
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-017"
  },
  {
   "id": "PRD-018",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "涉及权限、隐私、资金、合同、不可逆操作或验收口径的未决问题必须标为阻塞；阻塞未解除时任务停在「待澄清」，不得用默认值替人回答",
   "level": "hard",
   "check": "auto",
   "how": "状态转换检查：需求下有 blocking 为真且未回答的问题时，拒绝转为「就绪」",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-018"
  },
  {
   "id": "PRD-019",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "优先级只用 P0–P3，范围级别只用 必须/应该/可以/本次不做，风险只用 R0–R3，三者分开记，不互相替代",
   "level": "hard",
   "check": "auto",
   "how": "用 JSON Schema 限定三个字段的枚举值（范围级别的字段值为 must、should、could、wont）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-019"
  },
  {
   "id": "PRD-020",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "法规、合同、安全阻断类事项先作为硬门槛排进去，再对剩下的用 MoSCoW 或 RICE 排序；分数只作参考，不自动决定做不做",
   "level": "soft",
   "check": "manual",
   "how": "评审排期时检查硬门槛事项是否单列，排序结果是否有人确认",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-020"
  },
  {
   "id": "PRD-021",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "控制「必须」级的数量，并给本次工作定时间或预算上限；超出时先砍非关键范围、再分期，最后才加资源，不隐瞒超支",
   "level": "soft",
   "check": "manual",
   "how": "评审时看必须级占比和预算上限是否写明；超支时检查是否有记录和决定",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-021"
  },
  {
   "id": "PRD-022",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "加范围、降级或删除「必须」级 AC，必须有变更记录和有权人批准，并保留旧版本",
   "level": "hard",
   "check": "semi",
   "how": "需求和 AC 放在 Git 里，CI 比对批准版本与当前版本的差异；必须级 AC 的改动要求需求负责人批准（如写进 CODEOWNERS）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-022"
  },
  {
   "id": "PRD-023",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "每个实现任务关联到需求或 AC，或者关联到已批准的维护目标（如技术债、依赖升级）",
   "level": "soft",
   "check": "auto",
   "how": "脚本检查任务的 requirement_ids、acceptance_ids 或维护目标字段至少有一个非空",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-023"
  },
  {
   "id": "PRD-024",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "探索任务（摸清可行性、成本或假设）和实现任务分开；探索的产出是结论和建议，不自动获得上线授权",
   "level": "soft",
   "check": "semi",
   "how": "任务类型字段区分「实现」「探索」；探索任务的代码不直接合入发布分支，评审时检查",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-024"
  },
  {
   "id": "PRD-025",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "任务按能独立演示的用户价值纵向切分，写清要做、不做和允许改动的范围；多个任务共同实现一条 AC 时指定最终验证责任人",
   "level": "soft",
   "check": "manual",
   "how": "评审拆分结果时看每个任务能否单独演示和验证",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-025"
  },
  {
   "id": "PRD-026",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "depends_on 只表示阻塞依赖，信息关联另记；任务依赖图不得有环",
   "level": "hard",
   "check": "auto",
   "how": "脚本读取所有任务的 depends_on，做环检测，并检查引用的任务编号存在",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-026"
  },
  {
   "id": "PRD-027",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "任务状态只用「待澄清、就绪、进行中、待评审、待验收、已完成、已取消」，按规定的条件转换",
   "level": "hard",
   "check": "auto",
   "how": "任务系统或仓库脚本校验状态枚举和转换条件；不允许跳过「待验收」直接到「已完成」",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-027"
  },
  {
   "id": "PRD-028",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "需求批准和业务验收由有权限的人在任务系统或代码托管平台里留下记录；Agent 不代填批准人和时间，文件里手写的「批准人」不算批准",
   "level": "hard",
   "check": "auto",
   "how": "批准以平台的审批事件为准（如 PR 批准、issue 状态变更记录）；脚本校验批准者身份属于授权名单",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-028"
  },
  {
   "id": "PRD-029",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "按要降低的风险选原型档次，默认不做高保真；流程图和原型保留可编辑的源文件",
   "level": "soft",
   "check": "manual",
   "how": "评审时看原型档次是否和要回答的问题匹配；检查仓库或设计工具里有源文件而不只有图片",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-029"
  },
  {
   "id": "PRD-030",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "原型标明「探索」或「已批准设计」；未批准的原型和 AI 生成的原型不当定稿，也不当作功能已实现的证据",
   "level": "soft",
   "check": "semi",
   "how": "设计文件或原型目录带状态标记；评审时检查交付简报里没有拿原型当实现证据",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-030"
  },
  {
   "id": "PRD-031",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "有界面的需求，设计交付写清关联的需求和 AC、设计版本、用户路径、必需的状态和真实数据边界",
   "level": "soft",
   "check": "semi",
   "how": "交付清单模板固定这些栏目，CI 检查非空；内容由评审确认",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-031"
  },
  {
   "id": "PRD-032",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "客户数据或敏感数据进入云端原型工具、外部 AI 服务之前，先确认数据分类和授权",
   "level": "hard",
   "check": "manual",
   "how": "使用前由数据负责人按数据分类确认并留记录；能用合成或脱敏数据的就不用真实数据",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-032"
  },
  {
   "id": "PRD-033",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "交付简报写清关联的需求和任务、起止提交、用户可见的改动、没做的部分、逐条 AC 的结果和证据、偏差、风险和回滚方式",
   "level": "hard",
   "check": "semi",
   "how": "交付简报模板固定这些栏目，CI 检查非空、AC 结果状态合法；评审核对证据链接能打开",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-033"
  },
  {
   "id": "PRD-034",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "每次对外发布都要有发布说明：用户能感到的变化、已知问题和限制、数据或权限变化、迁移和回滚要求、没验证的部分",
   "level": "hard",
   "check": "semi",
   "how": "发布流程检查发布说明存在且这几栏非空；发布评审确认内容",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-034"
  },
  {
   "id": "PRD-035",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "发布后按约定周期复盘：预期指标、观测期、实际结果、副作用和被证伪的假设；不用 PR 数、代码量或 token 数衡量价值",
   "level": "soft",
   "check": "semi",
   "how": "发布时自动创建复盘任务并写明观测期；到期检查是否有结论",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-035"
  },
  {
   "id": "PRD-036",
   "topic": "06",
   "topicTitle": "产品与需求",
   "title": "用户反馈归到原有需求或新需求，再复核优先级；收集反馈只要必要的信息",
   "level": "soft",
   "check": "semi",
   "how": "反馈模板固定字段；定期统计没有关联需求的反馈",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/product/#prd-036"
  },
  {
   "id": "WEB-001",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "每个前端项目在 ADR 里写明项目类型（SPA、后台、内容站、SSR 站点、嵌入式组件、组件库）以及选用和放弃的框架",
   "level": "soft",
   "check": "semi",
   "how": "CI 检查仓库里有前端选型 ADR；评审时看新项目是否说明了放弃的选项",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-001"
  },
  {
   "id": "WEB-002",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "登录后才能用的应用默认构建为静态 SPA；公开可索引的内容默认静态生成；只有需要个性化服务端渲染时才用 SSR 框架",
   "level": "soft",
   "check": "manual",
   "how": "评审选型 ADR 时核对：选 SSR 的理由是否具体（SEO 的个性化页面、首屏数据必须服务端取），是否评估了部署、缓存一致性和安全更新",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-002"
  },
  {
   "id": "WEB-003",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "一个应用只用一套路由库，不混用两套路由",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查依赖清单里同时出现多个路由库（如 react-router 和 @tanstack/react-router）时报错",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-003"
  },
  {
   "id": "WEB-004",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "筛选、排序、分页、当前标签页等能从 URL 重建的视图状态放进路由的查询参数",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查：刷新页面或分享链接后视图是否还原",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-004"
  },
  {
   "id": "WEB-005",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "服务器数据交给请求缓存库或框架的 loader 管理，不复制进全局 store",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查全局 store 里是否存放了接口返回的实体列表；确需复制的写明理由",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-005"
  },
  {
   "id": "WEB-006",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "所有接口调用经过统一的 HTTP 客户端层，鉴权、错误转换、超时和重试只在这一层实现",
   "level": "soft",
   "check": "auto",
   "how": "用 lint 的 import 限制规则禁止在客户端层以外直接调用 fetch 或引入 HTTP 库",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-006"
  },
  {
   "id": "WEB-007",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "后端提供 OpenAPI 契约时，前端的接口类型从契约生成，生成文件不手改",
   "level": "hard",
   "check": "auto",
   "how": "CI 重新运行生成命令后执行 `git diff --exit-code`，有差异即失败",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-007"
  },
  {
   "id": "WEB-008",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "新增依赖前登记用途、备选、许可证、维护状态、包体积影响和需要的外部资源",
   "level": "soft",
   "check": "semi",
   "how": "PR 模板要求填写；CI 发现依赖清单新增直接依赖时检查 PR 描述是否有登记",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-008"
  },
  {
   "id": "WEB-009",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "商业许可、强 copyleft 或已归档的包未经许可审查不得引入；同一项目的免费版和付费版功能要分清",
   "level": "hard",
   "check": "auto",
   "how": "CI 对锁文件跑许可证扫描，命中禁用或待审许可证即失败；评审时核对精确包名",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-009"
  },
  {
   "id": "WEB-010",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "依赖的主版本升级单独提 PR，写明影响范围、迁移步骤和回滚方式，并跑完整 E2E；不批量升级多个主版本",
   "level": "soft",
   "check": "semi",
   "how": "CI 发现依赖主版本变化时，要求 PR 描述包含迁移和回滚小节",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-010"
  },
  {
   "id": "WEB-011",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "对话框、菜单、弹出层、下拉选择、组合框等交互组件，基于成熟的可访问基础组件库实现，不自己写焦点管理和键盘交互",
   "level": "hard",
   "check": "semi",
   "how": "评审时检查新组件里是否手写了焦点陷阱、`role=\"dialog\"`、方向键导航等逻辑；有则要求改用基础组件库",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-011"
  },
  {
   "id": "WEB-012",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "同一应用的公共组件层只用一套基础组件库，不混用多套",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查依赖清单里同时出现多套基础组件库（如 Base UI、Radix、React Aria、Ark UI）时报错，例外要有 ADR",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-012"
  },
  {
   "id": "WEB-013",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "以源码方式复制进仓库的组件（如 shadcn/ui）登记上游来源、引入时的版本和本地修改；业务页面复用公共组件，不另复制变体",
   "level": "soft",
   "check": "semi",
   "how": "维护一份组件清单文件；CI 检查公共组件目录里的文件都在清单中",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-013"
  },
  {
   "id": "WEB-014",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "虚拟列表、复杂表格、拖拽排序、日期时区计算、富文本、协同编辑、代码编辑器、PDF 渲染、终端模拟用成熟库，不自己实现",
   "level": "soft",
   "check": "manual",
   "how": "评审时识别自研的此类实现；确需自研的，先写一份「自研还是采用」的决策记录，列出候选库、缺口、测试和负责人",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-014"
  },
  {
   "id": "WEB-015",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "一个应用只用一套主样式方案；全局样式只在应用入口定义",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查是否同时引入多套原子类框架或 CSS-in-JS 库；lint 限制全局样式只能在入口文件引入",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-015"
  },
  {
   "id": "WEB-016",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "界面文案不硬编码在组件里，统一走 i18n 库；语言包随应用打包，不在运行时请求公网翻译服务",
   "level": "soft",
   "check": "semi",
   "how": "lint 规则查找 JSX 里的裸文本字面量；构建产物 URL 扫描",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-016"
  },
  {
   "id": "WEB-017",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "每个数据请求处理加载中、空结果、错误三种状态，并在离开页面时取消未完成的请求；每个路由有错误边界",
   "level": "soft",
   "check": "semi",
   "how": "组件测试用接口模拟返回空和错误；评审时检查请求是否接入取消信号",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-017"
  },
  {
   "id": "WEB-018",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "关键用户流程的 E2E 覆盖六种状态：成功、空结果、接口错误、超时、无权限、断网",
   "level": "soft",
   "check": "auto",
   "how": "用 E2E 框架的网络拦截模拟各状态；CI 检查关键流程的测试用例齐全",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-018"
  },
  {
   "id": "WEB-019",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "SSE 和 WebSocket 连接集中在通信层管理：断线指数退避加随机抖动重连、认证失效时重新认证、按事件编号去重，服务端支持时用游标续传",
   "level": "soft",
   "check": "semi",
   "how": "测试里模拟断线、重复和乱序事件；评审时检查组件内是否直接创建连接",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-019"
  },
  {
   "id": "WEB-020",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "日期时间用日期库或 Intl 处理；接口传输带时区偏移的 ISO 8601 字符串，不用 `new Date('YYYY-MM-DD')` 做业务计算",
   "level": "soft",
   "check": "semi",
   "how": "lint 规则查找对日期字符串直接 `new Date()` 的用法；测试在固定时区和夏令时切换日运行",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-020"
  },
  {
   "id": "WEB-021",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "不可信的 HTML 和 Markdown 不得未经净化注入 DOM；Markdown 渲染默认禁用原生 HTML，并校验链接协议",
   "level": "hard",
   "check": "auto",
   "how": "lint 禁用 `dangerouslySetInnerHTML`、`innerHTML`、`v-html`，例外只允许经过净化函数的调用点；安全测试放入 XSS 载荷",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-021"
  },
  {
   "id": "WEB-022",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "访问令牌和刷新令牌不得写入 localStorage 或 sessionStorage",
   "level": "hard",
   "check": "semi",
   "how": "代码扫描查找对 localStorage/sessionStorage 写入 token 类键名；评审登录实现",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-022"
  },
  {
   "id": "WEB-023",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "前端构建产物不依赖内联脚本和 eval，能在不含 unsafe-inline、unsafe-eval 的 CSP 下正常运行",
   "level": "hard",
   "check": "auto",
   "how": "E2E 在启用严格 CSP 的环境运行，检查控制台和 CSP 报告里没有违规",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-023"
  },
  {
   "id": "WEB-024",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "权限以服务端校验为准；前端隐藏按钮和菜单只是体验，不作为访问控制",
   "level": "hard",
   "check": "semi",
   "how": "每个受保护接口有服务端权限负例测试；评审时检查是否有只靠前端判断的权限",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-024"
  },
  {
   "id": "WEB-025",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "上传文件的类型、大小、配额和权限以服务端校验为准，前端的 accept 和大小提示只改善体验",
   "level": "hard",
   "check": "semi",
   "how": "服务端有绕过前端直接上传的负例测试",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-025"
  },
  {
   "id": "WEB-026",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "使用服务端组件（RSC）或 SSR 框架的项目，订阅框架安全公告，严重漏洞按安全修复时限升级",
   "level": "hard",
   "check": "auto",
   "how": "依赖漏洞扫描覆盖框架包，告警进入安全修复流程",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-026"
  },
  {
   "id": "WEB-027",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "页面加载的外部脚本、字体、CDN、iframe、统计和地图服务列成清单，写明域名、用途、传出的数据和离线替代",
   "level": "soft",
   "check": "auto",
   "how": "CI 扫描构建产物里的外部 URL，与清单比对，不在清单里的报错",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-027"
  },
  {
   "id": "WEB-028",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "私有化或断网部署的前端，字体、图标、样式、Worker（PDF.js、Monaco 等）全部随应用提供，不请求任何未批准的外部地址，并在断网环境验收",
   "level": "hard",
   "check": "auto",
   "how": "E2E 在屏蔽外网的环境运行核心流程，拦截并记录所有外部请求，有则失败",
   "applies": [
    "platform:web",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-028"
  },
  {
   "id": "WEB-029",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "前端错误和性能上报先去敏：不上报用户输入、正文、令牌和完整 URL 查询参数；含客户数据的上报不得发往未批准的公网服务",
   "level": "hard",
   "check": "semi",
   "how": "上报 SDK 配置统一的过滤钩子并有单元测试；评审出口清单",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-029"
  },
  {
   "id": "WEB-030",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "每次构建带唯一的发布编号，错误上报和性能数据都附带它",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查构建产物里注入了发布编号；监控里能按发布编号筛选",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-030"
  },
  {
   "id": "WEB-031",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "为首屏和主要路由设包体积预算并在 CI 检查，超出要在 PR 里说明",
   "level": "soft",
   "check": "auto",
   "how": "用 size-limit 或构建分析工具在 CI 比对预算",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-031"
  },
  {
   "id": "WEB-032",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "代码编辑器、图表、PDF 预览、富文本、白板、Office 预览按路由或交互懒加载，不进首屏包",
   "level": "soft",
   "check": "auto",
   "how": "构建分析检查首屏 chunk 里不含这些库",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-032"
  },
  {
   "id": "WEB-033",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "在生产环境用真实用户数据按 P75 衡量 LCP、INP、CLS，以官方「良好」阈值为目标；实验室跑分不代替真实用户数据",
   "level": "soft",
   "check": "auto",
   "how": "用 web-vitals 采集上报到自有收集端；CI 另跑 Lighthouse 防退步",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-033"
  },
  {
   "id": "WEB-034",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "首屏最大内容的图片不懒加载并写明宽高；字体自托管并设置 font-display",
   "level": "soft",
   "check": "auto",
   "how": "Lighthouse CI 检查 LCP 图片懒加载和布局偏移；构建扫描字体来源",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-034"
  },
  {
   "id": "WEB-035",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "嵌入第三方页面的组件做样式和全局变量隔离；宿主不可信或需强隔离时用 iframe",
   "level": "soft",
   "check": "manual",
   "how": "在一个带冲突样式的测试宿主页面里加载组件，检查双方样式互不影响",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-035"
  },
  {
   "id": "WEB-036",
   "topic": "07",
   "topicTitle": "Web 前端",
   "title": "可复用组件库把 react、react-dom 声明为 peerDependencies，构建时不打进产物，并提供 exports 和类型声明",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查 package.json 的 peerDependencies，并扫描产物中不含 React 源码",
   "applies": [
    "platform:web",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/web/#web-036"
  },
  {
   "id": "DSN-001",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "令牌源文件放在 Git 仓库并走 PR 评审；设计工具里的改动导出成文件差异后再合并",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查令牌改动是否来自仓库内的文件差异；设计工具的同步记录能对应到某个 PR",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-001"
  },
  {
   "id": "DSN-002",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "令牌文件采用 DTCG 格式：每个令牌都有 `$value`，类型由自身或所在组的 `$type` 确定，并通过 JSON Schema 校验",
   "level": "hard",
   "check": "auto",
   "how": "CI 用 JSON Schema 校验器（如 AJV）校验所有 `*.tokens.json`",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-002"
  },
  {
   "id": "DSN-003",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "令牌之间不得有循环别名或指向不存在令牌的引用",
   "level": "hard",
   "check": "auto",
   "how": "CI 解析全部引用并做图遍历，发现环或悬空引用时失败",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-003"
  },
  {
   "id": "DSN-004",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "每个支持的主题组合都必须完整解析：必需的语义键（文字、背景、边框、状态）一个不缺，构建时不得静默回退到默认主题",
   "level": "hard",
   "check": "auto",
   "how": "CI 枚举支持的主题组合逐个构建，对照必需键清单，缺键即失败",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-004"
  },
  {
   "id": "DSN-005",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "只用转换工具实测支持的 DTCG 特性；项目用到的每类令牌（颜色对象、复合令牌、主题解析等）都有兼容用例",
   "level": "soft",
   "check": "auto",
   "how": "维护一组覆盖所用特性的样例令牌和期望输出，CI 比对",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-005"
  },
  {
   "id": "DSN-006",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "生成的产物（CSS 变量、Swift、Kotlin、XML 资源等）不手改；CI 重新生成后必须与仓库内容一致",
   "level": "hard",
   "check": "auto",
   "how": "CI 跑令牌构建后执行 `git diff --exit-code` 检查生成目录",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-006"
  },
  {
   "id": "DSN-007",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "升级令牌转换工具前，在样例令牌上比对新旧输出，差异逐条确认",
   "level": "soft",
   "check": "semi",
   "how": "升级 PR 附上样例令牌的新旧输出差异",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-007"
  },
  {
   "id": "DSN-008",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "令牌分三层：原始值、语义、组件；平台适配层只做映射，不新增含义",
   "level": "soft",
   "check": "manual",
   "how": "评审令牌目录结构，看语义层是否只引用原始值、组件层是否只引用语义或原始值",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-008"
  },
  {
   "id": "DSN-009",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "在采用设计令牌的项目里，业务界面只用语义或组件令牌，不写新的裸颜色、间距、圆角、阴影、z-index、动效值；例外要登记",
   "level": "hard",
   "check": "auto",
   "how": "样式 lint 禁止这些属性使用字面量（Web 可用 Stylelint 规则，CSS-in-JS 用 ESLint 规则）；例外清单之外的命中让 CI 失败",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-009"
  },
  {
   "id": "DSN-010",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "令牌名表达用途而不是取值：用 `color.text.danger`，不用 `color.red`；统一小写点分",
   "level": "soft",
   "check": "auto",
   "how": "命名脚本检查语义层和组件层的名字里不含色名或数值",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-010"
  },
  {
   "id": "DSN-011",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "语义令牌和组件令牌写 `$description`，说明什么时候用",
   "level": "soft",
   "check": "auto",
   "how": "脚本列出缺描述的令牌，CI 给出警告",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-011"
  },
  {
   "id": "DSN-012",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "深色主题单独映射一套语义值，不用 `filter: invert()` 反转整页",
   "level": "hard",
   "check": "auto",
   "how": "样式 lint 禁止在根元素或大容器上使用 `filter: invert(...)`",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-012"
  },
  {
   "id": "DSN-013",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "主题按维度分开定义（如明暗、密度），列出支持的组合，不做任意自由组合",
   "level": "soft",
   "check": "manual",
   "how": "评审主题配置，看是否有明确的组合清单，且清单里的每个组合都在 DSN-004 的构建测试里",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-013"
  },
  {
   "id": "DSN-014",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "色阶可以用 OKLCH 生成，但发布值要做色域映射；输出广色域颜色时同时给 sRGB 回退",
   "level": "soft",
   "check": "semi",
   "how": "构建脚本检查每个颜色在 sRGB 内或带回退值，审查亮端和暗端的色相偏移",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-014"
  },
  {
   "id": "DSN-015",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "为每个主题维护前景/背景配对表，CI 按 09 的对比度阈值逐对计算；不能凭 OKLCH 的 L 值判断达标",
   "level": "hard",
   "check": "auto",
   "how": "脚本读取配对表和各主题的解析结果，按 WCAG 公式计算对比度，低于 09 的阈值即失败",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-015"
  },
  {
   "id": "DSN-016",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "状态色按语义家族定义（中性、信息、成功、警告、危险），不按色名定义",
   "level": "soft",
   "check": "auto",
   "how": "命名脚本检查状态类令牌使用语义家族名",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-016"
  },
  {
   "id": "DSN-017",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "图表配色用分类、顺序、发散三类色板令牌，不为新数据系列临时生成随机颜色",
   "level": "soft",
   "check": "semi",
   "how": "检查图表配置里的颜色是否引用色板令牌",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-017"
  },
  {
   "id": "DSN-018",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "字号和行高成对定义为排版令牌，组件只用排版令牌，不私造新的字号",
   "level": "soft",
   "check": "auto",
   "how": "样式 lint 禁止 `font-size`、`line-height` 使用字面量",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-018"
  },
  {
   "id": "DSN-019",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "默认用系统字体栈；栈里要有中文字体，并以通用字体族结尾",
   "level": "soft",
   "check": "semi",
   "how": "检查字体令牌的回退链；在目标系统上截图确认中文实际显示的字体",
   "applies": [
    "platform:web",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-019"
  },
  {
   "id": "DSN-020",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "随产品打包的字体必须允许再分发，并随包附带许可证文件；不得从操作系统或网上抽取商用字体打包",
   "level": "hard",
   "check": "semi",
   "how": "资产清单列出每个字体文件的来源、许可证和哈希；发布前人工核对",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-020"
  },
  {
   "id": "DSN-021",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "对中文字体做子集化后，在断网环境里截图检查不缺字",
   "level": "soft",
   "check": "semi",
   "how": "用覆盖常用字和业务专有字的样例页，在断网环境下截图人工检查",
   "applies": [
    "platform:web",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-021"
  },
  {
   "id": "DSN-022",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "数字列、金额、计时器使用等宽数字（`font-variant-numeric: tabular-nums`）",
   "level": "soft",
   "check": "semi",
   "how": "检查表格和数字组件的样式；截图看列是否对齐",
   "applies": [
    "platform:web",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-022"
  },
  {
   "id": "DSN-023",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "间距用一套有名字的阶梯，页面和组件只从阶梯里取值",
   "level": "soft",
   "check": "auto",
   "how": "样式 lint 禁止 `margin`、`padding`、`gap` 使用字面量",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-023"
  },
  {
   "id": "DSN-024",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "圆角和阴影各定少数几档令牌；阴影按层级含义命名，不按模糊半径命名",
   "level": "soft",
   "check": "auto",
   "how": "样式 lint 禁止 `border-radius`、`box-shadow` 使用字面量",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-024"
  },
  {
   "id": "DSN-025",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "z-index 只用项目的层级清单；第三方弹层组件的层级也纳入同一清单",
   "level": "soft",
   "check": "auto",
   "how": "扫描样式里的 `z-index`，不在清单内的值报错",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-025"
  },
  {
   "id": "DSN-026",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "紧凑模式用密度令牌调整尺寸和间距，不用 `transform: scale` 或 `zoom` 整页缩放",
   "level": "soft",
   "check": "auto",
   "how": "样式 lint 检查根元素和页面容器上的 `scale`、`zoom`",
   "applies": [
    "platform:web",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-026"
  },
  {
   "id": "DSN-027",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "动效时长和缓动曲线用动效令牌，不在组件里写字面量",
   "level": "soft",
   "check": "auto",
   "how": "样式 lint 禁止 `transition`、`animation` 的时长和缓动使用字面量",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-027"
  },
  {
   "id": "DSN-028",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "过渡动画优先只改 `transform` 和 `opacity`，大面积模糊和无限循环的闪烁骨架要有理由",
   "level": "soft",
   "check": "semi",
   "how": "评审动效代码；对可疑页面录性能面板看布局和重绘",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-028"
  },
  {
   "id": "DSN-029",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "每个项目选定一个主图标集；平台原生图标作为有映射的例外",
   "level": "soft",
   "check": "semi",
   "how": "检查依赖清单和资源目录里的图标来源",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-029"
  },
  {
   "id": "DSN-030",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "随产品分发的图标、插画、字体登记到资产清单：来源、许可证、版本或哈希",
   "level": "hard",
   "check": "semi",
   "how": "CI 检查资源目录里的每个文件都在清单中；新增条目由人核对许可证",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-030"
  },
  {
   "id": "DSN-031",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "断网或内网部署的产品，运行时不得从外部地址加载字体、图标和插画",
   "level": "hard",
   "check": "auto",
   "how": "在断网环境里跑页面测试；Web 端用 CSP 限制字体和图片来源，并检查网络请求记录",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-031"
  },
  {
   "id": "DSN-032",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "品牌标志和应用图标保留矢量母稿，各平台按官方规范分别导出，不用一张位图通用",
   "level": "soft",
   "check": "semi",
   "how": "脚本检查各平台图标的尺寸和格式；评审看母稿是否在仓库或资产库中",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-032"
  },
  {
   "id": "DSN-033",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "视觉例外登记在一份清单里，写明范围、原因、负责人和复核日期；过期由 CI 提示",
   "level": "soft",
   "check": "auto",
   "how": "例外清单用 schema 校验必填项，CI 检查复核日期是否已过",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-033"
  },
  {
   "id": "DSN-034",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "跨端统一令牌的名字和语义，不要求 px、pt、dp 数值逐一相同；原生端遵循平台的字体、图标和材质惯例",
   "level": "soft",
   "check": "manual",
   "how": "设计评审时按语义核对各端，不做像素比对",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-034"
  },
  {
   "id": "DSN-035",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "项目用了组件库时，由令牌生成组件库的主题配置，不并行维护两套令牌",
   "level": "soft",
   "check": "manual",
   "how": "评审主题配置的来源，确认组件库主题值来自令牌构建产物",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-035"
  },
  {
   "id": "DSN-036",
   "topic": "08",
   "topicTitle": "设计系统与视觉",
   "title": "视觉改动的 PR 写明令牌差异（或说明没有令牌改动）、受影响的组件和主题，并附改动前后的截图",
   "level": "soft",
   "check": "semi",
   "how": "PR 模板固定这些栏目；改到令牌或样式目录时 CI 检查栏目非空",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/design-system/#dsn-036"
  },
  {
   "id": "A11Y-001",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "Web 界面以 WCAG 2.2 AA（全部 A 级和 AA 级准则）为无障碍验收基线；不以 WCAG 3 草案作为验收或合同依据",
   "level": "hard",
   "check": "manual",
   "how": "评审需求文档、合同和验收清单，确认写明「WCAG 2.2 AA」；出现「WCAG 3 认证」「符合 WCAG 3」字样时退回",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-001"
  },
  {
   "id": "A11Y-002",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "自动扫描通过不能单独作为达标依据；声明符合 WCAG 前，关键流程必须有人工键盘检查和读屏检查记录",
   "level": "hard",
   "check": "manual",
   "how": "发布评审时检查每个关键流程是否有人工检查记录（测试人、日期、浏览器和读屏软件、结论）；只有自动扫描报告的不得写「符合 WCAG」",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-002"
  },
  {
   "id": "A11Y-003",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "在 CI 里对关键流程跑自动无障碍扫描，覆盖弹窗打开、表单报错、空结果等交互后的状态，不只扫首屏",
   "level": "soft",
   "check": "auto",
   "how": "E2E 测试在每个关键状态调用 axe 扫描，按 WCAG 2.x A/AA 标签过滤，有违规即失败",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-003"
  },
  {
   "id": "A11Y-004",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "能用原生 HTML 元素（button、a、input、select、label、dialog）实现的控件，不用 div 或 span 加 ARIA 模拟",
   "level": "hard",
   "check": "auto",
   "how": "无障碍 lint（如 jsx-a11y 的 no-static-element-interactions、click-events-have-key-events、interactive-supports-focus）报错即失败",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-004"
  },
  {
   "id": "A11Y-005",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "每个交互元素都有可访问名称、正确的角色和状态；有可见文字标签的，可访问名称要包含这段可见文字",
   "level": "hard",
   "check": "semi",
   "how": "axe 检查缺失名称和角色；评审时核对图标按钮的名称、展开/选中/禁用等状态是否同步，以及 aria-label 是否与可见文字一致",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-005"
  },
  {
   "id": "A11Y-006",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "有意义的图片和图标有准确的替代文本；纯装饰的图片用空 alt 或对读屏隐藏",
   "level": "hard",
   "check": "semi",
   "how": "axe 检查缺失 alt；替代文本写得对不对由评审人判断",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-006"
  },
  {
   "id": "A11Y-007",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "每个页面有描述性的标题、合理的标题层级和地标区域、跳到主内容的链接；链接文字说清去向",
   "level": "hard",
   "check": "semi",
   "how": "axe 检查 document-title、heading-order、landmark、bypass 等规则；评审时检查「点这里」「更多」这类说不清去向的链接",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-007"
  },
  {
   "id": "A11Y-008",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "不移动焦点的状态变化（保存成功、搜索结果数量、出错）要能被读屏感知；只对真正需要播报的内容用 live region，不逐字重复播报",
   "level": "hard",
   "check": "semi",
   "how": "E2E 断言状态区域带 role=\"status\" 或 role=\"alert\"；人工读屏检查播报次数和内容",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-008"
  },
  {
   "id": "A11Y-009",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "所有功能都能只用键盘完成，没有进得去出不来的键盘陷阱，焦点顺序与阅读顺序一致",
   "level": "hard",
   "check": "semi",
   "how": "E2E 用 Tab / Shift+Tab 走完关键流程并断言焦点顺序；人工拔掉鼠标走一遍",
   "applies": [
    "platform:web",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-009"
  },
  {
   "id": "A11Y-010",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "焦点指示始终可见；不去掉浏览器默认焦点样式而不给替代；获得焦点的元素不被吸顶栏、浮层完全遮挡",
   "level": "hard",
   "check": "semi",
   "how": "样式 lint 查找 把 outline 设为 none 或 0 且没有 `:focus-visible` 替代样式的写法；人工滚动并打开浮层检查遮挡",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-010"
  },
  {
   "id": "A11Y-011",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "模态对话框打开时焦点进入框内、Tab 在框内循环、Esc 可关闭（适用时），关闭后焦点回到触发它的元素，背后内容不可操作",
   "level": "hard",
   "check": "auto",
   "how": "E2E 测试打开对话框、按 Tab 循环、按 Esc，断言焦点回到触发按钮",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-011"
  },
  {
   "id": "A11Y-012",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "菜单、组合框、标签页、树、表格等复合组件按 WAI-ARIA APG 的键盘模式实现，并按模式逐键测试",
   "level": "soft",
   "check": "semi",
   "how": "评审时对照 APG 对应模式的键盘表逐项操作；组件测试覆盖方向键、Home/End、Esc、Enter/Space",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-012"
  },
  {
   "id": "A11Y-013",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "只用单个字母、数字或符号键的快捷键，必须能关闭、能改成带修饰键的组合，或只在对应组件获得焦点时生效",
   "level": "hard",
   "check": "semi",
   "how": "检查快捷键登记表里每个单字符快捷键是否标明关闭方式、重映射或作用域；E2E 验证关闭后不再触发",
   "applies": [
    "platform:web",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-013"
  },
  {
   "id": "A11Y-014",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "快捷键统一登记并分作用域，加入时检查冲突；每个快捷键都有可见的菜单或按钮作为替代入口；不覆盖复制粘贴、后退、刷新等浏览器和系统习惯",
   "level": "soft",
   "check": "semi",
   "how": "维护一份快捷键登记表（命令、作用域、各平台默认键、替代入口、是否可改），测试检查冲突；评审时检查组件里是否直接给 window 加键盘监听",
   "applies": [
    "platform:web",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-014"
  },
  {
   "id": "A11Y-015",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "输入法正在组字（`isComposing` 为真或 `keyCode` 为 229）时，不执行快捷键、不提交表单、不确认选项；输入框内默认不响应全局快捷键",
   "level": "hard",
   "check": "auto",
   "how": "组件测试派发 compositionstart/compositionend 和组字中的 keydown 事件，断言没有触发提交或命令",
   "applies": [
    "platform:web",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-015"
  },
  {
   "id": "A11Y-016",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "指针和触控目标至少 24×24 CSS px，或满足 WCAG 2.5.8 的例外（间距足够、页面上有等效的达标控件、行内链接、浏览器默认控件、必须保持原样）",
   "level": "hard",
   "check": "semi",
   "how": "axe 的 target-size 规则找候选；间距和例外由人确认",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-016"
  },
  {
   "id": "A11Y-017",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "主要操作和触屏上的常用操作，目标做到 44×44 CSS px",
   "level": "soft",
   "check": "semi",
   "how": "评审时抽查主要按钮的实际点击区域；可用可视化工具标出不足的目标",
   "applies": [
    "platform:web",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-017"
  },
  {
   "id": "A11Y-018",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "需要拖拽的操作都有单击即可完成的替代方式；悬停才出现的功能在键盘和触屏上也能触发；悬停或聚焦弹出的内容可关闭、可移入、不自动消失",
   "level": "hard",
   "check": "semi",
   "how": "评审时列出所有拖拽和悬停交互，逐一确认替代方式；人工用键盘和触屏各走一遍",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-018"
  },
  {
   "id": "A11Y-019",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "正文文字与背景的对比度至少 4.5:1，大字（至少 18pt，或至少 14pt 粗体）至少 3:1",
   "level": "hard",
   "check": "auto",
   "how": "axe 的 color-contrast 规则；设计令牌在定义时用对比度工具校验每组前景色和背景色",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-019"
  },
  {
   "id": "A11Y-020",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "文字放大到 200% 不丢内容和功能；在 320 CSS px 宽度下内容能重排、不需要横竖两个方向滚动；用户调大行距字距后不破版",
   "level": "hard",
   "check": "semi",
   "how": "E2E 在 320 px 宽度和 200% 文字缩放下跑关键流程并截图；人工检查截断和重叠",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-020"
  },
  {
   "id": "A11Y-021",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "控件边界、选中和焦点等状态指示、图表里理解内容必需的图形，与相邻颜色的对比度至少 3:1",
   "level": "hard",
   "check": "semi",
   "how": "设计令牌校验边框和状态色；评审时对照截图检查输入框边框、复选框、图表线条",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-021"
  },
  {
   "id": "A11Y-022",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "不只靠颜色传达信息；错误、必填、状态、图表系列同时用文字、图标或图案区分",
   "level": "hard",
   "check": "manual",
   "how": "评审时把截图转成灰度检查信息是否还能分辨；读屏检查状态是否有文字",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-022"
  },
  {
   "id": "A11Y-023",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "自动开始且持续超过 5 秒的移动、闪烁、滚动内容提供暂停、停止或隐藏；任何内容一秒内闪烁不超过三次",
   "level": "hard",
   "check": "manual",
   "how": "评审时列出轮播、跑马灯、自动播放视频和动画，检查暂停控件；对闪烁内容做人工检查",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-023"
  },
  {
   "id": "A11Y-024",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "系统开启「减少动态效果」时，关闭或替换非必要的位移、缩放、视差和自动滚动动画",
   "level": "soft",
   "check": "semi",
   "how": "E2E 模拟系统开启减少动态效果（prefers-reduced-motion 设为 reduce）并检查关键动画被关闭；评审动画代码是否读取该设置",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-024"
  },
  {
   "id": "A11Y-025",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "每个输入有可见标签；格式要求在输入前就显示；出错时在字段旁说明并能从页首汇总跳到字段；提交失败后保留已输入的内容",
   "level": "hard",
   "check": "semi",
   "how": "axe 检查标签关联；E2E 提交错误数据，断言错误与字段关联（aria-describedby）、焦点到汇总、输入未被清空",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-025"
  },
  {
   "id": "A11Y-026",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "涉及法律承诺、付款，或修改、删除用户数据的提交，至少满足一项：可撤销、提交前可检查修改、或需要明确确认",
   "level": "hard",
   "check": "manual",
   "how": "评审时列出这类操作，逐一确认撤销、复核页或确认步骤存在",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-026"
  },
  {
   "id": "A11Y-027",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "登录不要求用户记忆、转抄或解谜；允许粘贴和密码管理器自动填充",
   "level": "hard",
   "check": "auto",
   "how": "E2E 在登录和验证码输入框执行粘贴并断言成功；检查输入框没有禁用 autocomplete 或拦截 paste 事件",
   "applies": [
    "platform:web",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-027"
  },
  {
   "id": "A11Y-028",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "每次操作都给出看得见、读屏也能感知的结果（处理中、成功、失败及下一步）；不静默失败；重要错误不只用会自动消失的轻提示",
   "level": "hard",
   "check": "semi",
   "how": "E2E 模拟成功和失败，断言出现状态信息；评审时检查错误是否只放在几秒后消失的提示里",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-028"
  },
  {
   "id": "A11Y-029",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "长任务显示真实状态（排队、运行中、成功、失败、已取消）；没有可度量的总量时用不确定进度，不显示估算出来的百分比",
   "level": "soft",
   "check": "semi",
   "how": "评审进度条的数据来源；测试断开连接后重新查询状态而不是沿用旧值",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-029"
  },
  {
   "id": "A11Y-030",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "可逆的常规操作优先提供撤销，不每次弹确认；不可逆或对外产生影响的操作，确认时写明动作、对象、数量和后果",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查确认框文案是否包含具体对象和后果、按钮是否是具体动词；检查可撤销操作是否给了撤销入口",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-030"
  },
  {
   "id": "A11Y-031",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "乐观更新（先显示成功再等服务端）只用于低风险、可逆的操作，失败时回滚并告知；付款、授权、删除、对外发送等等服务端确认后再显示成功",
   "level": "soft",
   "check": "semi",
   "how": "E2E 模拟接口失败、延迟和冲突，断言界面回滚并提示；评审时检查高风险操作没有乐观更新",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-031"
  },
  {
   "id": "A11Y-032",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "多步表单和长时间编辑自动保存草稿；会丢失未保存内容的离开（关页、返回、切换路由）先提示",
   "level": "soft",
   "check": "semi",
   "how": "E2E 在填写中刷新、返回、断网后恢复，断言内容还在或出现提示",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-032"
  },
  {
   "id": "A11Y-033",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "按钮和菜单用「动词＋对象」；同一含义全产品用同一个词，区分删除、移除、归档、停用；维护一份界面术语表",
   "level": "soft",
   "check": "semi",
   "how": "术语表放在仓库里，文案检查脚本标出术语表中的禁用同义词；评审时检查泛用的「确定」「提交」",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-033"
  },
  {
   "id": "A11Y-034",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "错误提示说明哪里出错、原因（已知时）和怎么恢复，不责备用户；不向用户显示堆栈、SQL、内部路径和密钥，可附请求编号便于反馈",
   "level": "hard",
   "check": "semi",
   "how": "E2E 触发接口错误，扫描页面文字中是否出现堆栈、SQL 片段、文件路径特征；文案由评审人按模板检查",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-034"
  },
  {
   "id": "A11Y-035",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "AI 生成的内容在界面上区分「生成中」「草稿待审」「已人工确认」「已执行」；建议不显示成已执行；不显示没有校准依据的置信度百分比和查不到的引用",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查 AI 输出的状态标签与后台实际状态是否一一对应；抽查引用链接能否打开",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-035"
  },
  {
   "id": "A11Y-036",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "中文排版遵循 W3C 中文排版需求（CLReq）的标点和断行规则；不用全局正则在中英文之间强行插空格",
   "level": "soft",
   "check": "manual",
   "how": "准备一组含代码、网址、数字、单位的中英混排样例，人工审校渲染结果",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-036"
  },
  {
   "id": "A11Y-037",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "需要支持多语言的界面，所有用户可见文字（含 aria-label、title、alt、错误、空状态、邮件和通知模板）都放进资源文件，不硬编码",
   "level": "hard",
   "check": "auto",
   "how": "lint 规则（如 i18n 库配套的「禁止裸字符串」规则）扫描组件和模板，命中即失败；豁免写在配置里并注明原因",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-037"
  },
  {
   "id": "A11Y-038",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "按完整句子翻译，每条消息有稳定的 ID 和给译者的上下文说明；不把翻译片段和变量拼接成句子",
   "level": "hard",
   "check": "semi",
   "how": "lint 查找翻译函数结果与字符串或变量用 + 或模板字符串拼接的写法；评审新增消息是否带说明",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-038"
  },
  {
   "id": "A11Y-039",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "复数、性别和分支选择用 ICU 消息格式或 i18n 库自带的 CLDR 复数规则，不用 `count === 1` 判断",
   "level": "hard",
   "check": "semi",
   "how": "lint 查找翻译调用附近的 `=== 1`、`> 1` 等单复数判断；资源编译时校验每种语言的复数分支齐全",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-039"
  },
  {
   "id": "A11Y-040",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "日期、时间、数字、百分比、货币、列表和排序用 `Intl` 或同等的本地化接口，并显式传入语言、时区和币种",
   "level": "hard",
   "check": "semi",
   "how": "lint 查找不带参数的 `toLocaleString()`、手写的日期格式串和千分位处理；E2E 至少在两种语言和两个时区下运行",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-040"
  },
  {
   "id": "A11Y-041",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "页面根元素的 lang 与当前界面语言一致，切换语言时同步更新；夹杂的其他语言片段单独标 lang",
   "level": "hard",
   "check": "auto",
   "how": "axe 的 html-has-lang、html-lang-valid 规则；E2E 切换语言后断言 `document.documentElement.lang`",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-041"
  },
  {
   "id": "A11Y-042",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "启用从右到左书写的语言（如阿拉伯语、希伯来语）时，用 dir 属性控制方向，样式用 CSS 逻辑属性（margin-inline-start 等）代替 left/right；用户输入和混排内容用 dir=\"auto\" 或 bdi 隔离",
   "level": "hard",
   "check": "semi",
   "how": "样式 lint 标出物理方向属性（left、right、margin-left 等）；RTL 语言下跑关键流程截图并人工检查",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-042"
  },
  {
   "id": "A11Y-043",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "CI 检查翻译资源：缺失键、多余键、各语言占位符不一致、复数分支缺失、消息语法错误，任一项失败即阻断",
   "level": "hard",
   "check": "auto",
   "how": "在 CI 里运行 i18n 库的抽取和编译或校验命令，并对比各语言文件的键和占位符",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-043"
  },
  {
   "id": "A11Y-044",
   "topic": "09",
   "topicTitle": "交互、无障碍与国际化",
   "title": "用伪本地化（拉长文字、加带重音的字符、首尾加标记）跑关键页面，检查硬编码、截断和拼接；文本容器不写死宽高、不强制单行",
   "level": "soft",
   "check": "auto",
   "how": "在 CI 里生成伪语言包并跑关键流程截图；评审时检查文字容器的固定高度和单行截断",
   "applies": [
    "platform:web",
    "platform:desktop",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/a11y-i18n/#a11y-044"
  },
  {
   "id": "TS-001",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "生产环境的 Node 只用仍在维护期的 LTS 主线并跟进安全补丁；不用已停止维护、奇数号短期版本或预发布版本",
   "level": "hard",
   "check": "auto",
   "how": "CI 打印 `node --version` 并对照官方发布计划；容器镜像扫描标出停止维护的运行时",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-001"
  },
  {
   "id": "TS-002",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "只提交一种锁文件，CI 用冻结锁文件的方式安装（`pnpm install --frozen-lockfile` 或 `npm ci`）",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查仓库里只有一种锁文件；安装命令带冻结参数，锁文件与清单不一致时失败",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-002"
  },
  {
   "id": "TS-003",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "package.json 用 `engines` 声明支持的 Node 范围，用 `packageManager` 写包管理器的精确版本",
   "level": "soft",
   "check": "auto",
   "how": "脚本检查两个字段存在，且 CI、镜像里的版本落在声明范围内",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-003"
  },
  {
   "id": "TS-004",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "构建和 CI 里不临时安装 `latest` 版本的工具，不用 `npx 包名@latest` 这类不锁版本的调用",
   "level": "hard",
   "check": "semi",
   "how": "在 CI 配置、Dockerfile、脚本里搜索 `@latest`、`npm install -g` 和不带版本的 `npx`，评审确认",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-004"
  },
  {
   "id": "TS-005",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "依赖的安装脚本默认不执行，只对审查过的包逐个放行；禁止整体放开",
   "level": "hard",
   "check": "auto",
   "how": "pnpm 保持 `strictDepBuilds` 开启，用 `allowBuilds` 列白名单；CI 检查没有 `dangerouslyAllowAllBuilds`；白名单文件写进 CODEOWNERS",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-005"
  },
  {
   "id": "TS-006",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "开启发布冷却期：刚发布的新版本过一段时间才允许安装；紧急安全修复用精确版本单独放行",
   "level": "soft",
   "check": "auto",
   "how": "检查 pnpm 配置里的 `minimumReleaseAge` 没有被设为 0；例外写进 `minimumReleaseAgeExclude` 并经评审",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-006"
  },
  {
   "id": "TS-007",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "发布到 npm 的包用 OIDC 可信发布并生成来源证明，CI 里不保存长期有效的 npm 令牌",
   "level": "hard",
   "check": "auto",
   "how": "检查发布工作流使用可信发布配置、带来源证明；仓库和 CI 密钥里没有 npm 长期令牌",
   "applies": [
    "lang:ts",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-007"
  },
  {
   "id": "TS-008",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "库发布前用 `npm pack` 打包，在一个干净的项目里安装，验证导入、类型和最低支持的 Node 版本",
   "level": "soft",
   "check": "auto",
   "how": "CI 任务：打包、在空目录安装、分别用 ESM 导入和类型检查，在最低和最新 Node 版本上各跑一次",
   "applies": [
    "lang:ts",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-008"
  },
  {
   "id": "TS-009",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "tsconfig 显式写 `strict` 为 true，不靠默认值；不得为了通过检查关掉 strict 的子项",
   "level": "hard",
   "check": "auto",
   "how": "脚本检查生效的 tsconfig（`tsc --showConfig`）里 strict 为 true，且没有把 `strictNullChecks` 等子项设为 false",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-009"
  },
  {
   "id": "TS-010",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "新项目在 strict 之外再开启 `noUncheckedIndexedAccess`、`noImplicitOverride`、`noFallthroughCasesInSwitch`、`verbatimModuleSyntax`，并显式写 `types`",
   "level": "soft",
   "check": "auto",
   "how": "脚本检查 tsconfig 中这些选项",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-010"
  },
  {
   "id": "TS-011",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "CI 必须单独跑一次类型检查（`tsc --noEmit`）；Node 直接运行 .ts、esbuild、tsx、Vite 编译成功都不算类型通过",
   "level": "hard",
   "check": "auto",
   "how": "CI 中有独立的类型检查步骤，失败即阻断合并",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-011"
  },
  {
   "id": "TS-012",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "直接用 Node 运行 .ts 的项目必须开启 `erasableSyntaxOnly` 和 `verbatimModuleSyntax`，不用 enum、带运行时代码的 namespace、参数属性，类型导入写 `import type`",
   "level": "hard",
   "check": "auto",
   "how": "tsconfig 开启这两项后，`tsc --noEmit` 会直接报出不可擦除的语法和缺少 `type` 的类型导入",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-012"
  },
  {
   "id": "TS-013",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "测试、脚本和工具配置文件也纳入类型检查，不用 `exclude` 把它们排除在外",
   "level": "soft",
   "check": "semi",
   "how": "检查 `tsc --listFilesOnly` 的输出包含测试和脚本目录；需要不同设置时用单独的 tsconfig 并在 CI 里一起检查",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-013"
  },
  {
   "id": "TS-014",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "不用 `any`、非空断言 `!` 和 `as` 绕过类型错误；必须抑制时用 `@ts-expect-error` 写明原因，禁用 `@ts-ignore` 和 `@ts-nocheck`",
   "level": "soft",
   "check": "auto",
   "how": "开启 lint 的相应规则（Biome 的 `noExplicitAny`、`noNonNullAssertion`，typescript-eslint 的 `ban-ts-comment` 等）；例外按 02 的要求写理由",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-014"
  },
  {
   "id": "TS-015",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "来自外部的数据（HTTP 请求、环境变量、消息、文件、数据库 JSON 列、第三方和模型返回的结果）进入业务前，用运行时校验库解析，不得只用 `as` 声明类型",
   "level": "hard",
   "check": "semi",
   "how": "lint 找候选（`JSON.parse` 或 `response.json()` 后直接 `as`、typescript-eslint 的 `no-unsafe-*` 规则）；评审检查每个入口都有 schema 解析",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-015"
  },
  {
   "id": "TS-016",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "配置只在一个入口模块里读取环境变量并用 schema 解析，其他代码从这个模块取值，不直接读 `process.env`",
   "level": "soft",
   "check": "auto",
   "how": "lint 禁止在配置模块以外使用 `process.env`（如 Biome 的 `noProcessEnv` 或 ESLint 的 `n/no-process-env`）",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-016"
  },
  {
   "id": "TS-017",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "新项目统一用 ESM：package.json 设 `\"type\": \"module\"`；Node 直接运行的代码用 `NodeNext` 模块解析，相对导入写文件扩展名",
   "level": "soft",
   "check": "auto",
   "how": "检查 package.json 和 tsconfig 的模块设置；`tsc --noEmit` 在 NodeNext 下会报出缺扩展名的相对导入",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-017"
  },
  {
   "id": "TS-018",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "不依赖 tsconfig 的 `paths` 别名在运行时生效；需要内部别名时用 package.json 的 `imports`（以 `#` 开头的子路径导入）",
   "level": "soft",
   "check": "auto",
   "how": "检查 tsconfig 是否配置了 `paths`；有的话在评审时确认运行时有对应的解析方式",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-018"
  },
  {
   "id": "TS-019",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "库默认只发布 ESM，用 `exports` 字段声明入口和类型文件；确有 CJS 使用方时才发布双格式",
   "level": "soft",
   "check": "semi",
   "how": "检查 package.json 的 `exports`；双格式包要说明使用方并在 TS-008 的打包测试里覆盖两种导入",
   "applies": [
    "lang:ts",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-019"
  },
  {
   "id": "TS-020",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "仓库只用一个格式化工具（默认 Biome），CI 用检查模式运行，有差异即失败",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `biome ci .`（或用 Prettier 的项目运行 `prettier --check .`）；检查没有同时配置第二个格式化工具",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-020"
  },
  {
   "id": "TS-021",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "每个 Promise 都要 await、return，或显式用 `void` 标记并处理错误；用 lint 的漏 await 规则检查",
   "level": "hard",
   "check": "auto",
   "how": "开启 typescript-eslint 的 `no-floating-promises` 和 `no-misused-promises`（或 Biome 的对应规则，覆盖不全时见 TS-022）",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-021"
  },
  {
   "id": "TS-022",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "需要完整的类型感知 lint 时，在 Biome 之外只补 typescript-eslint 的类型规则，格式化仍只归一个工具",
   "level": "soft",
   "check": "semi",
   "how": "检查 ESLint 配置只启用类型相关规则、没有格式化类规则；CI 里两者都跑",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-022"
  },
  {
   "id": "TS-023",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "发出网络请求和其他 IO 时传入 AbortSignal 并设超时，取消信号一路传到最底层的调用",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查 `fetch` 和客户端库调用是否带 `signal`；lint 找出没有 signal 参数的 `fetch` 作为候选",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-023"
  },
  {
   "id": "TS-024",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "对数量不定的集合并发处理时限制并发数，不直接 `Promise.all(items.map(...))`",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查对查询结果、文件列表等集合的 `Promise.all`；用 `p-limit` 等库限制并发",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-024"
  },
  {
   "id": "TS-025",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "启动子进程用参数数组（`spawn`、`execFile`），不把含外部输入的字符串交给 shell（`exec`、`shell` 设为 true）",
   "level": "hard",
   "check": "semi",
   "how": "SAST 规则或 lint 标出 `exec`、`execSync` 和 `shell` 设为 true 的调用，评审确认参数不含外部输入",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-025"
  },
  {
   "id": "TS-026",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "不把 Node 的 `vm` 模块或权限模型（`--permission`）当作运行不可信代码的沙箱",
   "level": "hard",
   "check": "manual",
   "how": "评审时检查执行外部提交代码、插件或 Agent 生成代码的地方，确认隔离靠容器、虚拟机或独立系统用户",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-026"
  },
  {
   "id": "TS-027",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "单元测试默认用 Vitest；一个仓库只维护一套单元测试框架",
   "level": "soft",
   "check": "auto",
   "how": "检查依赖清单里没有同时出现两套单元测试框架（存量迁移期间除外，需写明期限）",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-027"
  },
  {
   "id": "TS-028",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "解析、序列化、校验、金额和日期计算这类有明确不变量的代码，用 fast-check 写属性测试",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查这类模块是否有属性测试；失败时记录种子和缩减后的最小样例（见 04）",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-028"
  },
  {
   "id": "TS-029",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "不把 `.only` 提交进主干，不用 `|| true`、跳过测试或删断言的方式让 CI 变绿",
   "level": "hard",
   "check": "auto",
   "how": "lint 禁止 `.only`（如 `@vitest/eslint-plugin` 的 `no-focused-tests`）；CI 配置检查测试命令没有 `|| true`；新增 `.skip` 要求写原因和任务编号",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-029"
  },
  {
   "id": "TS-030",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "SQL 一律参数化或用查询构建器拼装，不把外部输入拼进 SQL 字符串；原始 SQL 接口的调用要评审",
   "level": "hard",
   "check": "semi",
   "how": "SAST 规则标出字符串拼接的 SQL 和 `sql.raw`、`$queryRawUnsafe` 等原始接口，评审确认没有外部输入",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-030"
  },
  {
   "id": "TS-031",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "数据库迁移用所选查询层或独立迁移工具的 CLI 生成 SQL 文件入库，SQL 经人工评审后再执行",
   "level": "soft",
   "check": "semi",
   "how": "迁移目录写进 CODEOWNERS；评审时看生成的 SQL，不只看 schema 定义的变化",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-031"
  },
  {
   "id": "TS-032",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "每个进程只创建一个数据库连接池，连接上限和获取超时可配置；不在每个请求里新建连接或连接池",
   "level": "soft",
   "check": "semi",
   "how": "评审时搜索 `new Pool`、`new Client` 的位置；压测时观察数据库连接数",
   "applies": [
    "lang:ts",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-032"
  },
  {
   "id": "TS-033",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "从 OpenAPI 生成的 TypeScript 类型和客户端放在只读目录，不手改；CI 重新生成后用 `git diff --exit-code` 检查",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行生成命令，有差异即失败",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-033"
  },
  {
   "id": "TS-034",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "服务端用结构化日志库（默认 Pino）并配置敏感字段脱敏，不用 `console.log` 打业务日志",
   "level": "soft",
   "check": "auto",
   "how": "lint 在服务代码里禁止 `console`（如 Biome 的 `noConsole`），CLI 和脚本可放开；测试里检查日志不含令牌等字段",
   "applies": [
    "lang:ts",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-034"
  },
  {
   "id": "TS-035",
   "topic": "10",
   "topicTitle": "TypeScript 与 Node",
   "title": "升级 Node 或 TypeScript 的大版本时，先在 CI 里并行跑新旧两个版本，确认编译、lint、测试、生成器和构建工具都支持后再切换",
   "level": "soft",
   "check": "semi",
   "how": "升级 PR 附版本矩阵的 CI 结果；评审检查依赖编译器接口的工具是否兼容",
   "applies": [
    "lang:ts"
   ],
   "url": "https://plumbrule.dev/topics/typescript/#ts-035"
  },
  {
   "id": "GO-001",
   "topic": "11",
   "topicTitle": "Go",
   "title": "构建和运行用的 Go 版本必须在官方支持窗口内（最新的两个大版本），并及时升级到最新补丁版",
   "level": "hard",
   "check": "auto",
   "how": "CI 打印 `go version`，与官方发布列表比对；超出支持窗口时失败",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-001"
  },
  {
   "id": "GO-002",
   "topic": "11",
   "topicTitle": "Go",
   "title": "go.mod 里的 `go` 行写项目需要的最低语言版本；构建用的工具链用 `toolchain` 行或构建镜像固定到补丁版，CI 和生产一致",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查构建镜像的 Go 版本与 `toolchain` 行或版本文件一致",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-002"
  },
  {
   "id": "GO-003",
   "topic": "11",
   "topicTitle": "Go",
   "title": "go.mod 和 go.sum 必须入库；CI 用 `-mod=readonly` 构建，并检查 `go mod tidy` 后没有差异",
   "level": "hard",
   "check": "auto",
   "how": "CI 设 `GOFLAGS=-mod=readonly`，运行 `go mod tidy -diff` 或 tidy 后 `git diff --exit-code go.mod go.sum`，再跑 `go mod verify`",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-003"
  },
  {
   "id": "GO-004",
   "topic": "11",
   "topicTitle": "Go",
   "title": "发布构建和 CI 里不得用 `go install …@latest` 或 `go get …@latest`；开发工具固定版本",
   "level": "hard",
   "check": "auto",
   "how": "CI 扫描脚本和 Makefile 中的 `@latest`；工具用 go.mod 的 `tool` 指令或写死版本安装",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-004"
  },
  {
   "id": "GO-005",
   "topic": "11",
   "topicTitle": "Go",
   "title": "私有模块用 `GOPRIVATE`（或 `GONOSUMDB`）限定到具体域名或路径，不在组织层关闭校验和数据库（`GOSUMDB=off`）",
   "level": "hard",
   "check": "semi",
   "how": "检查 CI 环境变量和开发者模板；`GOSUMDB=off` 出现时要求说明",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-005"
  },
  {
   "id": "GO-006",
   "topic": "11",
   "topicTitle": "Go",
   "title": "要求断网交付的 Go 项目，必须用内部模块代理或 vendor 目录，在无外网环境下重建一次并保留记录",
   "level": "hard",
   "check": "auto",
   "how": "在隔离网络的 CI 里设 `GOPROXY=off`（vendor）或指向内部代理、`GOTOOLCHAIN=local`，跑构建和测试",
   "applies": [
    "lang:go",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-006"
  },
  {
   "id": "GO-007",
   "topic": "11",
   "topicTitle": "Go",
   "title": "所有 Go 代码用 gofmt 格式化、goimports 整理导入；CI 检查没有格式差异",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `golangci-lint fmt --diff`，有输出即失败；或 `gofmt -l .` 输出非空即失败",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-007"
  },
  {
   "id": "GO-008",
   "topic": "11",
   "topicTitle": "Go",
   "title": "golangci-lint 的配置文件入库，用工具当前的配置格式；CI 先校验配置（`golangci-lint config verify`）再运行",
   "level": "hard",
   "check": "auto",
   "how": "CI 依次运行 `golangci-lint config verify` 和 `golangci-lint run ./...`；v1 配置先用 `golangci-lint migrate` 转换",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-008"
  },
  {
   "id": "GO-009",
   "topic": "11",
   "topicTitle": "Go",
   "title": "在默认 linter 之外显式启用一组推荐 linter，不用 `default: all` 再大面积关闭",
   "level": "soft",
   "check": "semi",
   "how": "评审 `.golangci.yml`：检查推荐集合是否启用、关闭项是否写明理由",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-009"
  },
  {
   "id": "GO-010",
   "topic": "11",
   "topicTitle": "Go",
   "title": "`//nolint` 必须写明具体 linter 和理由；用 nolintlint 强制",
   "level": "hard",
   "check": "auto",
   "how": "启用 nolintlint，并设 `require-specific: true`、`require-explanation: true`",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-010"
  },
  {
   "id": "GO-011",
   "topic": "11",
   "topicTitle": "Go",
   "title": "CI 必须运行 `go vet ./...` 并且没有报告",
   "level": "hard",
   "check": "auto",
   "how": "CI 单独运行 `go vet ./...`，或在 golangci-lint 中启用 govet",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-011"
  },
  {
   "id": "GO-012",
   "topic": "11",
   "topicTitle": "Go",
   "title": "不得丢弃函数返回的 error；确实可以忽略的，显式写 `_ =` 并注释原因",
   "level": "hard",
   "check": "auto",
   "how": "启用 errcheck",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-012"
  },
  {
   "id": "GO-013",
   "topic": "11",
   "topicTitle": "Go",
   "title": "补上下文时用 `fmt.Errorf(\"…: %w\", err)` 包装；判断错误用 `errors.Is` / `errors.As`，不用 `==` 比较包装过的错误，也不匹配错误消息文本",
   "level": "hard",
   "check": "auto",
   "how": "启用 errorlint（检查 `%w`、比较和类型断言）",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-013"
  },
  {
   "id": "GO-014",
   "topic": "11",
   "topicTitle": "Go",
   "title": "公开的库只对愿意长期承诺给调用方的错误用 `%w`；不想暴露内部依赖的错误用 `%v`，并导出哨兵错误或错误类型供判断",
   "level": "soft",
   "check": "manual",
   "how": "评审公开包的错误返回：被 `%w` 包装的底层错误是否成了 API 的一部分",
   "applies": [
    "lang:go",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-014"
  },
  {
   "id": "GO-015",
   "topic": "11",
   "topicTitle": "Go",
   "title": "不用 panic 表达可预期的错误，panic 不越过包边界；`init` 里不做网络、文件等外部 I/O",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查非测试代码中的 `panic`、`log.Fatal` 和 `init` 函数",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-015"
  },
  {
   "id": "GO-016",
   "topic": "11",
   "topicTitle": "Go",
   "title": "会做 I/O 或可能阻塞的函数，第一个参数接收 `ctx context.Context` 并传给下游，调用带 Context 的 API（如 `QueryContext`、`http.NewRequestWithContext`）",
   "level": "hard",
   "check": "semi",
   "how": "启用 noctx（发 HTTP、执行 SQL 没带 ctx）和 contextcheck（没有沿用上游 ctx）；评审新增的公开函数签名",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-016"
  },
  {
   "id": "GO-017",
   "topic": "11",
   "topicTitle": "Go",
   "title": "不把 context 存进结构体字段；在请求处理路径上不用 `context.Background()` 或 `context.TODO()` 代替传入的 ctx",
   "level": "hard",
   "check": "semi",
   "how": "启用 containedctx 和 contextcheck；评审中出现的 `context.Background()` 要说明为什么不用上游 ctx",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-017"
  },
  {
   "id": "GO-018",
   "topic": "11",
   "topicTitle": "Go",
   "title": "`context.WithCancel` / `WithTimeout` / `WithDeadline` 返回的 cancel 必须调用，通常紧跟一行 `defer cancel()`",
   "level": "hard",
   "check": "auto",
   "how": "go vet 的 lostcancel 检查",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-018"
  },
  {
   "id": "GO-019",
   "topic": "11",
   "topicTitle": "Go",
   "title": "context 只携带请求范围的值（追踪编号、认证信息等），不用来传可选参数或依赖对象；键用包内私有类型",
   "level": "soft",
   "check": "manual",
   "how": "评审 `context.WithValue` 的调用",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-019"
  },
  {
   "id": "GO-020",
   "topic": "11",
   "topicTitle": "Go",
   "title": "每个 goroutine 在启动处写明由谁停止、何时退出；服务停止时所有 goroutine 都能结束",
   "level": "hard",
   "check": "semi",
   "how": "评审每个 `go` 语句的退出条件；在停机和关键组件的测试中用 goleak 检查泄漏",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-020"
  },
  {
   "id": "GO-021",
   "topic": "11",
   "topicTitle": "Go",
   "title": "并发扇出用 `errgroup.WithContext` 加 `SetLimit`（或 semaphore）限制并发数，不按输入数量无限启动 goroutine",
   "level": "soft",
   "check": "manual",
   "how": "评审循环里的 `go` 语句和 WaitGroup 用法",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-021"
  },
  {
   "id": "GO-022",
   "topic": "11",
   "topicTitle": "Go",
   "title": "CI 在支持的平台上用 `go test -race` 跑测试，发现竞态即失败",
   "level": "hard",
   "check": "auto",
   "how": "CI 至少有一个 job 运行 `go test -race ./...`",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-022"
  },
  {
   "id": "GO-023",
   "topic": "11",
   "topicTitle": "Go",
   "title": "关键的后台任务不用裸 goroutine 或 `time.Ticker` 承担；需要持久、重试、定时的工作交给任务队列或调度系统",
   "level": "soft",
   "check": "manual",
   "how": "评审 `go func()` 和 Ticker 是否承载了进程重启后不能丢的工作",
   "applies": [
    "lang:go",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-023"
  },
  {
   "id": "GO-024",
   "topic": "11",
   "topicTitle": "Go",
   "title": "单元测试用标准库 testing 加表驱动和子测试（`t.Run`）；复杂结构比较用 go-cmp",
   "level": "soft",
   "check": "manual",
   "how": "评审新增测试的写法",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-024"
  },
  {
   "id": "GO-025",
   "topic": "11",
   "topicTitle": "Go",
   "title": "依赖替身优先用手写的 fake；需要生成 mock 时用 go.uber.org/mock，不得引入已归档的 golang/mock",
   "level": "hard",
   "check": "auto",
   "how": "depguard 禁止导入 `github.com/golang/mock`",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-025"
  },
  {
   "id": "GO-026",
   "topic": "11",
   "topicTitle": "Go",
   "title": "解析外部输入的代码（协议、文件格式、URL、配置）写 fuzz 测试；PR 只跑种子语料，长时间 fuzz 放到定时任务",
   "level": "soft",
   "check": "semi",
   "how": "评审解析类包是否有 `FuzzXxx`；定时任务运行 `go test -fuzz=… -fuzztime=…`",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-026"
  },
  {
   "id": "GO-027",
   "topic": "11",
   "topicTitle": "Go",
   "title": "集成测试用 testcontainers-go 拉起和生产同款的数据库等依赖，镜像固定摘要",
   "level": "soft",
   "check": "semi",
   "how": "评审集成测试的依赖启动方式",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-027"
  },
  {
   "id": "GO-028",
   "topic": "11",
   "topicTitle": "Go",
   "title": "新代码的日志用 log/slog（生产用 JSONHandler），全项目一套；不新增 `log.Printf` 和第二套日志库",
   "level": "soft",
   "check": "auto",
   "how": "depguard 或 forbidigo 禁止在非 main 包中使用 `log` 包的打印函数和未批准的日志库",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-028"
  },
  {
   "id": "GO-029",
   "topic": "11",
   "topicTitle": "Go",
   "title": "含密码、令牌、个人信息的类型实现 `slog.LogValuer`（或 `String()`）返回脱敏后的值",
   "level": "soft",
   "check": "semi",
   "how": "评审敏感类型定义；日志测试断言输出中没有原文",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-029"
  },
  {
   "id": "GO-030",
   "topic": "11",
   "topicTitle": "Go",
   "title": "对外监听的 `http.Server` 必须显式设置 `ReadHeaderTimeout`、`ReadTimeout`、`WriteTimeout`（流式接口除外）和 `IdleTimeout`；不用无超时的 `http.ListenAndServe`",
   "level": "hard",
   "check": "auto",
   "how": "gosec 的 G112（缺 ReadHeaderTimeout）和 G114（使用默认 ListenAndServe）",
   "applies": [
    "lang:go",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-030"
  },
  {
   "id": "GO-031",
   "topic": "11",
   "topicTitle": "Go",
   "title": "HTTP 客户端必须有超时（`Client.Timeout` 或请求 ctx 的截止时间），不用 `http.Get` 和默认 `http.DefaultClient` 调外部服务；响应体必须关闭",
   "level": "hard",
   "check": "auto",
   "how": "启用 noctx 和 bodyclose；forbidigo 禁止 `http.Get`、`http.Post`、`http.DefaultClient`",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-031"
  },
  {
   "id": "GO-032",
   "topic": "11",
   "topicTitle": "Go",
   "title": "读取请求体前用 `http.MaxBytesReader` 限制大小，不对不可信输入直接 `io.ReadAll`",
   "level": "hard",
   "check": "semi",
   "how": "评审 handler 中的请求体读取；边界测试发送超限请求，断言返回 413",
   "applies": [
    "lang:go",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-032"
  },
  {
   "id": "GO-033",
   "topic": "11",
   "topicTitle": "Go",
   "title": "停机用 `signal.NotifyContext` 接收信号，`http.Server.Shutdown` 带截止时间；后台 worker 处理完手头工作再退出",
   "level": "soft",
   "check": "semi",
   "how": "集成测试发送 SIGTERM，断言进行中的请求完成、进程在截止时间内退出",
   "applies": [
    "lang:go",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-033"
  },
  {
   "id": "GO-034",
   "topic": "11",
   "topicTitle": "Go",
   "title": "SQL 参数一律用占位符传递，不用 `fmt.Sprintf` 或字符串拼接把值写进 SQL；表名、列名等动态标识符只能从白名单里选",
   "level": "hard",
   "check": "auto",
   "how": "gosec 的 G201、G202；评审动态 SQL 的标识符来源",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-034"
  },
  {
   "id": "GO-035",
   "topic": "11",
   "topicTitle": "Go",
   "title": "事务用带 ctx 的 `BeginTx` 开启，紧跟 `defer tx.Rollback(…)`，并检查 `Commit` 返回的错误；事务里的语句都用 tx 执行",
   "level": "hard",
   "check": "semi",
   "how": "评审事务代码；集成测试覆盖中途出错和 ctx 取消时事务被回滚",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-035"
  },
  {
   "id": "GO-036",
   "topic": "11",
   "topicTitle": "Go",
   "title": "查询结果的 rows 必须关闭，遍历结束后检查 `rows.Err()`",
   "level": "hard",
   "check": "auto",
   "how": "启用 sqlclosecheck 和 rowserrcheck",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-036"
  },
  {
   "id": "GO-037",
   "topic": "11",
   "topicTitle": "Go",
   "title": "生成的代码（sqlc、OpenAPI、protobuf、mock）不手改；CI 重新生成后 `git diff --exit-code` 必须无差异",
   "level": "hard",
   "check": "auto",
   "how": "CI 用固定版本的生成器运行 `go generate ./...` 或 `sqlc generate`，再 `git diff --exit-code`",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-037"
  },
  {
   "id": "GO-038",
   "topic": "11",
   "topicTitle": "Go",
   "title": "包之间的依赖方向写进 go-arch-lint 配置并在 CI 检查；禁止导入的具体包用 depguard；用 `internal/` 控制可见性",
   "level": "soft",
   "check": "auto",
   "how": "CI 运行 `go-arch-lint check`；golangci-lint 启用 depguard",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-038"
  },
  {
   "id": "GO-039",
   "topic": "11",
   "topicTitle": "Go",
   "title": "CI 必须运行 `govulncheck ./...`，存在被代码实际调用到的已知漏洞时失败；发布前在最终提交上再跑一次",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 govulncheck，非零退出即失败；断网环境用内部镜像的漏洞库并记录库的更新日期",
   "applies": [
    "lang:go"
   ],
   "url": "https://plumbrule.dev/topics/go/#go-039"
  },
  {
   "id": "PY-001",
   "topic": "12",
   "topicTitle": "Python",
   "title": "项目用 pyproject.toml 声明元数据和依赖；新项目不写有动态安装逻辑的 setup.py",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查仓库根目录有 pyproject.toml 且含 `[project]` 表；新项目出现 setup.py 时要求说明",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-001"
  },
  {
   "id": "PY-002",
   "topic": "12",
   "topicTitle": "Python",
   "title": "应用必须提交锁文件；CI 先校验锁文件与 pyproject.toml 一致，再按锁安装",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `uv lock --check` 和 `uv sync --locked`（Poetry 项目用 `poetry check --lock`，PDM 项目用 `pdm lock --check`）",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-002"
  },
  {
   "id": "PY-003",
   "topic": "12",
   "topicTitle": "Python",
   "title": "供别人安装的库在依赖里写版本范围，不写精确版本；开发用的锁文件只服务于本仓库的 CI",
   "level": "soft",
   "check": "semi",
   "how": "评审 `project.dependencies` 中是否出现 `==` 精确版本",
   "applies": [
    "lang:python",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-003"
  },
  {
   "id": "PY-004",
   "topic": "12",
   "topicTitle": "Python",
   "title": "使用私有包索引时，内部包名只从内部索引解析；不用 pip 的 `--extra-index-url` 把公网和内网索引混在一起",
   "level": "hard",
   "check": "semi",
   "how": "检查 CI、Dockerfile 和 pip 配置中的 `--extra-index-url`；uv 项目检查 `[[tool.uv.index]]` 的配置和 `explicit` 设置",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-004"
  },
  {
   "id": "PY-005",
   "topic": "12",
   "topicTitle": "Python",
   "title": "新增依赖时写明用途、许可证和维护状态；能用标准库简单完成的不加依赖",
   "level": "soft",
   "check": "semi",
   "how": "依赖清单变化的 PR 要求说明这三项；许可证用 27 规定的工具扫描",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-005"
  },
  {
   "id": "PY-006",
   "topic": "12",
   "topicTitle": "Python",
   "title": "CI 用 pip-audit 扫描锁定的依赖；断网环境用离线漏洞库快照，扫描失败不当作零漏洞",
   "level": "soft",
   "check": "auto",
   "how": "CI 运行 `pip-audit`（对 uv 项目先 `uv export` 出带哈希的需求文件再扫描），失败即阻断",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-006"
  },
  {
   "id": "PY-007",
   "topic": "12",
   "topicTitle": "Python",
   "title": "要断网交付的项目，按目标操作系统、CPU 架构和 Python 版本准备完整的 wheel 包，在断网环境里用 `--no-index` 安装并启动验证",
   "level": "hard",
   "check": "auto",
   "how": "CI 在无外网的容器里用 `pip install --no-index --find-links` 或 `uv sync --offline` 安装后运行冒烟测试",
   "applies": [
    "lang:python",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-007"
  },
  {
   "id": "PY-008",
   "topic": "12",
   "topicTitle": "Python",
   "title": "生产环境只用 Python 官方仍在维护（修复期或安全期）的版本；版本到期前完成升级",
   "level": "hard",
   "check": "auto",
   "how": "CI 读取 `.python-version` 或 `requires-python`，与 Python 官方版本状态表比对，已到期的版本报错",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-008"
  },
  {
   "id": "PY-009",
   "topic": "12",
   "topicTitle": "Python",
   "title": "新应用固定一个仍处于修复期的小版本（写进 `.python-version` 和 `requires-python`）；升级到新的小版本前先在 CI 里跑完整测试",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查 `.python-version` 存在，并定期在下一个小版本上跑一遍测试",
   "applies": [
    "lang:python",
    "kind:service",
    "kind:cli"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-009"
  },
  {
   "id": "PY-010",
   "topic": "12",
   "topicTitle": "Python",
   "title": "库在 `requires-python` 里声明的每个小版本都要在 CI 里测试；没测过的版本不声明支持",
   "level": "hard",
   "check": "auto",
   "how": "CI 矩阵覆盖 `requires-python` 范围内所有仍在维护的小版本",
   "applies": [
    "lang:python",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-010"
  },
  {
   "id": "PY-011",
   "topic": "12",
   "topicTitle": "Python",
   "title": "自由线程（无 GIL）版本不作为默认运行时；要用时先确认所有原生扩展都支持，并实测并发行为",
   "level": "soft",
   "check": "manual",
   "how": "评审时查看原生依赖的自由线程兼容记录和并发测试结果",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-011"
  },
  {
   "id": "PY-012",
   "topic": "12",
   "topicTitle": "Python",
   "title": "`ruff check` 和 `ruff format --check` 必须在 CI 里通过；不同时用 Black、isort、flake8 等第二套格式化或 lint 工具",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `ruff check .` 和 `ruff format --check .`；检查仓库里是否还有 Black、isort 的配置",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-012"
  },
  {
   "id": "PY-013",
   "topic": "12",
   "topicTitle": "Python",
   "title": "行内例外必须指明规则号并写理由：禁止不带规则号的 `# noqa` 和 `# type: ignore`",
   "level": "hard",
   "check": "auto",
   "how": "启用 Ruff 的 `PGH004`（不带规则号的 noqa）、`PGH003`（不带错误码的 type ignore）和 `RUF100`（多余的 noqa）",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-013"
  },
  {
   "id": "PY-014",
   "topic": "12",
   "topicTitle": "Python",
   "title": "选定一个类型检查器在 CI 里做门禁；公开接口和跨模块调用的函数写类型注解",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `mypy`（或 `pyright`）并要求零错误；新代码建议 mypy 的 `strict` 模式",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-014"
  },
  {
   "id": "PY-015",
   "topic": "12",
   "topicTitle": "Python",
   "title": "ty 在发布稳定版之前不作为唯一的类型检查门禁，只做旁路对比",
   "level": "soft",
   "check": "semi",
   "how": "检查 CI 中阻断合并的类型检查命令",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-015"
  },
  {
   "id": "PY-016",
   "topic": "12",
   "topicTitle": "Python",
   "title": "发布的库包含 `py.typed` 标记文件，让使用方能用到库里的类型注解",
   "level": "soft",
   "check": "auto",
   "how": "构建后检查 wheel 里包含 `py.typed`",
   "applies": [
    "lang:python",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-016"
  },
  {
   "id": "PY-017",
   "topic": "12",
   "topicTitle": "Python",
   "title": "本地 Git 钩子沿用 03 的约定：默认 Lefthook；以 Python 为主的仓库可以用 pre-commit；一个仓库只用一个",
   "level": "soft",
   "check": "semi",
   "how": "检查仓库里是否同时存在 lefthook.yml 和 .pre-commit-config.yaml",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-017"
  },
  {
   "id": "PY-018",
   "topic": "12",
   "topicTitle": "Python",
   "title": "不用裸 `except:` 和静默吞掉的 `except Exception`；转换异常时用 `raise ... from err` 保留原因",
   "level": "hard",
   "check": "auto",
   "how": "启用 Ruff 的 `E722`（裸 except）、`BLE001`（宽泛捕获）、`B904`（except 中抛出新异常未带 from）、`S110`（try-except-pass）",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-018"
  },
  {
   "id": "PY-019",
   "topic": "12",
   "topicTitle": "Python",
   "title": "SQL 用参数绑定或查询构造器，不用字符串拼接或 f-string 拼 SQL",
   "level": "hard",
   "check": "semi",
   "how": "启用 Ruff 的 `S608`（疑似拼接的 SQL）找候选，人工确认",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-019"
  },
  {
   "id": "PY-020",
   "topic": "12",
   "topicTitle": "Python",
   "title": "不可信数据不用 `pickle`、`yaml.load`（非安全加载器）、`eval` 反序列化；`subprocess` 不用 `shell=True` 拼接外部输入",
   "level": "hard",
   "check": "auto",
   "how": "启用 Ruff 的 `S301`（pickle）、`S506`（不安全的 yaml.load）、`S307`（eval）、`S602`/`S604`（shell=True）",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-020"
  },
  {
   "id": "PY-021",
   "topic": "12",
   "topicTitle": "Python",
   "title": "时间用带时区的 datetime，时区用标准库 zoneinfo；不用 `datetime.utcnow()` 和不带时区的 `datetime.now()`",
   "level": "soft",
   "check": "auto",
   "how": "启用 Ruff 的 `DTZ` 规则组",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-021"
  },
  {
   "id": "PY-022",
   "topic": "12",
   "topicTitle": "Python",
   "title": "`async def` 里不调用阻塞操作（同步 HTTP 库、`time.sleep`、同步文件和数据库调用、长时间 CPU 计算）；必须调用时放到线程池或独立进程",
   "level": "hard",
   "check": "semi",
   "how": "启用 Ruff 的 `ASYNC` 规则组找候选（如 ASYNC210 同步 HTTP、ASYNC251 time.sleep），评审确认；并发测试中观察事件循环延迟",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-022"
  },
  {
   "id": "PY-023",
   "topic": "12",
   "topicTitle": "Python",
   "title": "简单需求默认写同步代码；用 async 要有明确的并发收益（大量并发 I/O）",
   "level": "soft",
   "check": "manual",
   "how": "评审时看引入 async 的理由",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-023"
  },
  {
   "id": "PY-024",
   "topic": "12",
   "topicTitle": "Python",
   "title": "并发任务用 `asyncio.TaskGroup` 等结构化方式管理；`create_task` 创建的任务要保存引用并等待结果",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查未保存引用的 `asyncio.create_task(...)` 调用；Ruff 的 `RUF006` 可找出部分候选",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-024"
  },
  {
   "id": "PY-025",
   "topic": "12",
   "topicTitle": "Python",
   "title": "不吞掉 `asyncio.CancelledError`；捕获后做完清理要重新抛出",
   "level": "hard",
   "check": "manual",
   "how": "评审时检查 `except BaseException`、`except asyncio.CancelledError` 的处理分支",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-025"
  },
  {
   "id": "PY-026",
   "topic": "12",
   "topicTitle": "Python",
   "title": "每个外部 HTTP 调用设置超时；用 `requests` 时必须显式传 `timeout`",
   "level": "hard",
   "check": "auto",
   "how": "启用 Ruff 的 `S113`（requests 调用缺少 timeout）；HTTPX 不得把超时设为 `None`",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-026"
  },
  {
   "id": "PY-027",
   "topic": "12",
   "topicTitle": "Python",
   "title": "HTTP 客户端、数据库引擎和连接池在应用生命周期内复用并在退出时关闭，不在每个请求里新建",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查请求处理函数里是否创建 `httpx.Client`、`create_engine` 等；集成测试检查连接数",
   "applies": [
    "lang:python",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-027"
  },
  {
   "id": "PY-028",
   "topic": "12",
   "topicTitle": "Python",
   "title": "库代码只通过 `logging.getLogger(__name__)` 记日志，不配置处理器；应用在入口处统一配置结构化输出；生产代码不用 `print` 记日志",
   "level": "soft",
   "check": "auto",
   "how": "启用 Ruff 的 `T20`（print）规则；评审库代码中是否调用 `logging.basicConfig`",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-028"
  },
  {
   "id": "PY-029",
   "topic": "12",
   "topicTitle": "Python",
   "title": "外部输入（HTTP 请求体、外部 JSON、配置、工具返回）在边界处用 Pydantic v2 模型校验后再使用；新代码不用 `pydantic.v1` 兼容层",
   "level": "hard",
   "check": "semi",
   "how": "评审边界处是否直接读取未经校验的 `dict`；搜索 `pydantic.v1` 导入",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-029"
  },
  {
   "id": "PY-030",
   "topic": "12",
   "topicTitle": "Python",
   "title": "配置用 pydantic-settings 在启动时集中加载并校验；缺失或非法时启动失败",
   "level": "soft",
   "check": "semi",
   "how": "启动测试中去掉必填配置，确认进程报错退出",
   "applies": [
    "lang:python",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-030"
  },
  {
   "id": "PY-031",
   "topic": "12",
   "topicTitle": "Python",
   "title": "生产环境的表结构只由迁移工具（Alembic 或 Django 迁移）变更，不在启动时调用 `metadata.create_all`",
   "level": "hard",
   "check": "semi",
   "how": "搜索生产代码中的 `create_all` 调用；CI 检查模型变化是否带了迁移文件（Django 用 `makemigrations --check`，Alembic 用 `alembic check`）",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-031"
  },
  {
   "id": "PY-032",
   "topic": "12",
   "topicTitle": "Python",
   "title": "Alembic 自动生成的迁移必须人工审查后再提交",
   "level": "hard",
   "check": "manual",
   "how": "评审迁移文件，重点看重命名、类型变化、约束和数据迁移",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-032"
  },
  {
   "id": "PY-033",
   "topic": "12",
   "topicTitle": "Python",
   "title": "涉及数据库语义的测试接真实的同款数据库（如用 testcontainers 起 PostgreSQL），不用 SQLite 冒充",
   "level": "hard",
   "check": "semi",
   "how": "检查集成测试的数据库连接配置",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-033"
  },
  {
   "id": "PY-034",
   "topic": "12",
   "topicTitle": "Python",
   "title": "不能丢的后台任务交给独立的任务队列执行，不用 FastAPI 的 BackgroundTasks 或进程内线程承担",
   "level": "hard",
   "check": "manual",
   "how": "评审时检查 BackgroundTasks 中执行的操作是否允许丢失",
   "applies": [
    "lang:python",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-034"
  },
  {
   "id": "PY-035",
   "topic": "12",
   "topicTitle": "Python",
   "title": "测试用 pytest；解析器、序列化、数据转换等输入空间大的代码加 Hypothesis 属性测试",
   "level": "soft",
   "check": "semi",
   "how": "评审时看解析与转换模块是否有属性测试；Hypothesis 失败时保留缩减后的样例",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-035"
  },
  {
   "id": "PY-036",
   "topic": "12",
   "topicTitle": "Python",
   "title": "异步测试用 pytest-asyncio 并显式设置事件循环的作用域；测试之间不共享事件循环上的全局状态",
   "level": "soft",
   "check": "semi",
   "how": "检查 pytest 配置中的 asyncio 模式和循环作用域设置",
   "applies": [
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-036"
  },
  {
   "id": "PY-037",
   "topic": "12",
   "topicTitle": "Python",
   "title": "发布的库用 `src/` 目录结构，并从构建出的 wheel 安装后运行测试",
   "level": "soft",
   "check": "auto",
   "how": "CI 先 `uv build`，再在干净环境安装 wheel 跑测试",
   "applies": [
    "lang:python",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-037"
  },
  {
   "id": "PY-038",
   "topic": "12",
   "topicTitle": "Python",
   "title": "从 CI 发布到 PyPI 时用 Trusted Publishing（短期令牌），不在 CI 里保存长期 API 令牌",
   "level": "soft",
   "check": "semi",
   "how": "检查发布流水线的认证方式和仓库密钥列表",
   "applies": [
    "lang:python",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/python/#py-038"
  },
  {
   "id": "RS-001",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "应用、服务和命令行工具在仓库根目录提交 rust-toolchain.toml，把工具链固定到具体补丁版，并列出需要的组件和 target",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查文件存在，`channel` 是完整的补丁版本号而不是 `stable`；构建日志打印 `rustc -V` 与之对照",
   "applies": [
    "lang:rust",
    "kind:service",
    "kind:cli"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-001"
  },
  {
   "id": "RS-002",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "供别人引用的库在 Cargo.toml 写 rust-version 声明 MSRV，CI 用这个版本实际构建并测试",
   "level": "hard",
   "check": "auto",
   "how": "CI 增加一个用 MSRV 工具链的任务（或 `cargo hack check --rust-version`），失败即阻断",
   "applies": [
    "lang:rust",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-002"
  },
  {
   "id": "RS-003",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "抬高库的 MSRV 要写进变更日志并说明原因，不随每次 stable 发布自动抬高",
   "level": "soft",
   "check": "semi",
   "how": "CI 发现 `rust-version` 变化时，要求 PR 描述和变更日志里有对应说明",
   "applies": [
    "lang:rust",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-003"
  },
  {
   "id": "RS-004",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "新项目用当前最新的 edition；迁移 edition 单独一个 PR，用 cargo fix --edition 完成",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查各 crate 的 `edition` 字段；迁移 PR 里只含 edition 相关改动",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-004"
  },
  {
   "id": "RS-005",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "应用、服务和命令行工具必须提交 Cargo.lock，CI 和发布构建一律加 --locked",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查 Cargo.lock 已入库；所有 `cargo build`、`cargo test`、`cargo clippy` 命令带 `--locked`，锁文件需要变化时失败",
   "applies": [
    "lang:rust",
    "kind:service",
    "kind:cli"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-005"
  },
  {
   "id": "RS-006",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "库也默认提交 Cargo.lock；另在 CI 里跑一次用最新兼容依赖的构建和测试",
   "level": "soft",
   "check": "auto",
   "how": "定时任务或 PR 任务先 `cargo update` 再构建测试，失败时提醒",
   "applies": [
    "lang:rust",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-006"
  },
  {
   "id": "RS-007",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "依赖版本不写 `*`；git 依赖必须固定 rev；依赖只能来自批准的 registry 或镜像",
   "level": "hard",
   "check": "auto",
   "how": "cargo-deny 的 `[sources]` 只允许批准的 registry 和 git 地址，`[bans]` 设 `wildcards = \"deny\"`",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-007"
  },
  {
   "id": "RS-008",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "新增依赖在 PR 里写明用途；只开需要的 feature，关掉不用的默认 feature",
   "level": "soft",
   "check": "semi",
   "how": "评审时看 Cargo.toml 和 Cargo.lock 的变化；用 `cargo tree -e features` 查看实际启用的 feature，用 cargo-machete 等工具找未使用的依赖",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-008"
  },
  {
   "id": "RS-009",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "代码必须通过 rustfmt；CI 跑 `cargo fmt --all -- --check`",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行该命令，非零退出即失败；如有 rustfmt.toml 则入库",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-009"
  },
  {
   "id": "RS-010",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "CI 用固定的工具链跑 `cargo clippy --all-targets --locked -- -D warnings`，不得有告警",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行该命令；feature 互斥时按 feature 组合分别跑，不硬加 `--all-features`",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-010"
  },
  {
   "id": "RS-011",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "lint 级别统一写在 Cargo.toml 的 [lints] 表；多 crate 工作区写在 [workspace.lints]，各成员用 `lints.workspace = true` 继承",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查各成员 crate 都继承了工作区 lint 配置；评审时看是否有散落在 crate 根的 `#![deny(...)]`",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-011"
  },
  {
   "id": "RS-012",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "pedantic、nursery 等可选 lint 组逐条评估后启用，不整组设为 deny",
   "level": "soft",
   "check": "semi",
   "how": "评审时看 lint 配置里是否有整组 `pedantic = \"deny\"` 之类的写法",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-012"
  },
  {
   "id": "RS-013",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "局部关闭 lint 时写明理由；优先用 `#[expect(lint, reason = \"...\")]`，不用不带理由的 `#[allow]`",
   "level": "soft",
   "check": "auto",
   "how": "启用 Clippy 的 `allow_attributes_without_reason`（和 `allow_attributes`）检查",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-013"
  },
  {
   "id": "RS-014",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "不需要 unsafe 的 crate 设 `unsafe_code = \"forbid\"`；需要的 unsafe 集中在少数模块，并对外提供安全的接口",
   "level": "soft",
   "check": "auto",
   "how": "[lints] 表里配置 `unsafe_code`；CI 用 grep 或 cargo-geiger 统计 unsafe 出现的位置",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-014"
  },
  {
   "id": "RS-015",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "每个 unsafe 块前写 `// SAFETY:` 注释，说明满足了哪些前提；每个 `unsafe fn` 写 `# Safety` 文档说明调用方要满足的前提",
   "level": "hard",
   "check": "auto",
   "how": "Clippy 的 `undocumented_unsafe_blocks` 和 `missing_safety_doc` 设为 deny；`unsafe_op_in_unsafe_fn` 不得放宽",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-015"
  },
  {
   "id": "RS-016",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "新增或修改 unsafe 代码必须由指定的评审人批准，作者不能自己批准",
   "level": "hard",
   "check": "semi",
   "how": "CI 检测改动里出现 `unsafe` 时打标签并要求指定评审人；集中放 unsafe 的模块写进 CODEOWNERS",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-016"
  },
  {
   "id": "RS-017",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "含 unsafe 或原始指针操作的代码，用 Miri 跑相关单元测试",
   "level": "soft",
   "check": "auto",
   "how": "CI 用 nightly 工具链跑 `cargo miri test`（可只跑相关模块）",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-017"
  },
  {
   "id": "RS-018",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "库对外暴露具体的错误类型（如用 thiserror 定义的枚举），不在公开接口里返回 anyhow::Error 或 Box<dyn Error>",
   "level": "soft",
   "check": "semi",
   "how": "评审公开接口；用 cargo-semver-checks 发现错误类型的破坏性变化",
   "applies": [
    "lang:rust",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-018"
  },
  {
   "id": "RS-019",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "生产代码里可预期的失败（输入、网络、文件、数据库）不得用 unwrap、expect、panic 处理",
   "level": "hard",
   "check": "semi",
   "how": "Clippy 开启 `unwrap_used`、`expect_used`、`panic`；clippy.toml 设 `allow-unwrap-in-tests = true`、`allow-expect-in-tests = true` 豁免测试；剩下的用 `#[expect(..., reason = \"...\")]` 逐处说明",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-019"
  },
  {
   "id": "RS-020",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "公开函数会 panic 或返回错误时，在文档里写 `# Panics` 和 `# Errors` 小节",
   "level": "soft",
   "check": "auto",
   "how": "Clippy 开启 `missing_panics_doc`、`missing_errors_doc`",
   "applies": [
    "lang:rust",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-020"
  },
  {
   "id": "RS-021",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "async 代码里不直接做阻塞调用或长时间 CPU 计算；阻塞操作用 spawn_blocking，CPU 密集任务交给 Rayon 或专用线程池",
   "level": "hard",
   "check": "semi",
   "how": "评审时检查 async 函数里的 `std::fs`、`std::thread::sleep`、同步数据库驱动、重计算循环；压测时用 tokio-console 或 tracing 的耗时数据找长时间不让出的任务",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-021"
  },
  {
   "id": "RS-022",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "不在 `.await` 期间持有 std 或 parking_lot 的同步锁",
   "level": "hard",
   "check": "auto",
   "how": "Clippy 的 `await_holding_lock`、`await_holding_refcell_ref` 设为 deny",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-022"
  },
  {
   "id": "RS-023",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "任务之间的 channel 默认用有界的；用无界 channel 要写明为什么不会无限增长",
   "level": "soft",
   "check": "semi",
   "how": "用 grep 找 `unbounded_channel`，评审时确认理由",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-023"
  },
  {
   "id": "RS-024",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "spawn 出的任务要有归属：用 JoinSet 或 TaskTracker 管理，停机时发出取消并限时等待结束；需要保证完成的工作不能只靠 tokio::spawn",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查每个 `tokio::spawn` 的 JoinHandle 是否被持有或交给任务组；停机路径有取消信号（如 CancellationToken）",
   "applies": [
    "lang:rust",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-024"
  },
  {
   "id": "RS-025",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "每个外部调用都设超时；HTTP 客户端在进程内复用同一个 Client，并配置连接和整体超时",
   "level": "hard",
   "check": "semi",
   "how": "评审时检查 `reqwest::Client::builder()` 是否设置了 `timeout`、`connect_timeout`；其他调用用 `tokio::time::timeout` 包裹；故障注入测试验证",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-025"
  },
  {
   "id": "RS-026",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "非幂等的写请求不做自动重试，除非接口支持幂等键；重试和幂等语义按 16 执行",
   "level": "soft",
   "check": "semi",
   "how": "评审重试中间件（如 reqwest-retry、backon）的作用范围，确认只覆盖幂等方法或带幂等键的请求",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-026"
  },
  {
   "id": "RS-027",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "日志和追踪用 tracing；库只发事件和 span，不初始化 subscriber，只有应用入口初始化一次",
   "level": "soft",
   "check": "auto",
   "how": "CI 用 grep 检查库 crate 里没有 `tracing_subscriber` 的初始化调用、没有 `println!` / `eprintln!` 打日志（Clippy 的 `print_stdout`、`print_stderr`）",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-027"
  },
  {
   "id": "RS-028",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "密钥和敏感字段不进日志、span 和 Debug 输出；持有密钥的类型用 secrecy 之类的包装或手写脱敏的 Debug",
   "level": "hard",
   "check": "semi",
   "how": "评审时检查含密钥字段的结构体是否 `#[derive(Debug)]`；日志测试断言输出里没有测试密钥；密钥扫描见 25",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-028"
  },
  {
   "id": "RS-029",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "用 cargo-nextest 跑测试时，必须另外跑 `cargo test --doc`",
   "level": "hard",
   "check": "auto",
   "how": "CI 同时有 `cargo nextest run --locked` 和 `cargo test --doc --locked` 两步",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-029"
  },
  {
   "id": "RS-030",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "解析器、编解码器和处理外部输入的函数，用 proptest 做属性测试；面向不可信输入的，再用 cargo-fuzz 做模糊测试",
   "level": "soft",
   "check": "semi",
   "how": "评审时看这些模块是否有属性测试；模糊测试放定时任务跑，发现的崩溃样本加入回归测试",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-030"
  },
  {
   "id": "RS-031",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "自己写的并发原语（原子操作、无锁结构、自定义同步）用 loom 测试",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查使用 `std::sync::atomic` 实现同步逻辑的模块是否有 loom 测试",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-031"
  },
  {
   "id": "RS-032",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "insta 快照的差异由人审查后接受；CI 里不自动写入或接受快照",
   "level": "hard",
   "check": "auto",
   "how": "CI 设 `INSTA_UPDATE=no`（或使用 `cargo insta test --check`），快照不一致即失败；快照文件改动在 PR 里单独评审",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-032"
  },
  {
   "id": "RS-033",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "CI 运行 cargo-deny 检查漏洞、许可证、重复与禁用的 crate、依赖来源；deny.toml 入库",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `cargo deny check`，非零退出即失败；忽略某条公告时在 deny.toml 写明理由和复查日期",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-033"
  },
  {
   "id": "RS-034",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "断网或私有化交付的构建，用 cargo vendor 或内部 registry 镜像准备依赖，并用 `cargo build --release --locked --offline` 验证",
   "level": "hard",
   "check": "auto",
   "how": "CI 里有一个禁网的构建任务执行该命令",
   "applies": [
    "lang:rust",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-034"
  },
  {
   "id": "RS-035",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "SQL 一律用参数绑定（`query!` 宏或 `.bind()`），不用 format! 拼接用户输入",
   "level": "hard",
   "check": "semi",
   "how": "用 grep 或 Semgrep 找传给 `sqlx::query` / `query_as` 的 `format!` 结果；动态拼接的标识符只能来自白名单",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-035"
  },
  {
   "id": "RS-036",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "用 SQLx 编译期检查宏且 CI 不连数据库时，提交 .sqlx 目录，CI 跑 `cargo sqlx prepare --check`",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `cargo sqlx prepare --check`；构建时不设 `DATABASE_URL`，确认走的是离线缓存",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-036"
  },
  {
   "id": "RS-037",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "迁移文件入库，用所选库配套的迁移工具（SQLx 用 `sqlx migrate`）执行；迁移原则按 17",
   "level": "soft",
   "check": "semi",
   "how": "评审迁移文件；确认生产环境由发布流程单独执行迁移，而不是每个实例启动时各跑一遍",
   "applies": [
    "lang:rust",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-037"
  },
  {
   "id": "RS-038",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "FFI 边界上不让 panic 跨越 `extern \"C\"`；跨边界的类型用 `#[repr(C)]`，不直接传 String、Vec 或普通 Rust 结构体",
   "level": "hard",
   "check": "semi",
   "how": "`improper_ctypes`、`improper_ctypes_definitions` 不得放宽；评审时检查每个导出函数是否用 `catch_unwind` 兜住 panic 或明确使用 `extern \"C-unwind\"`",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-038"
  },
  {
   "id": "RS-039",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "给 Python、Node、C++ 写绑定用成熟的绑定库（PyO3、napi-rs、cxx），不手写一层 C ABI",
   "level": "soft",
   "check": "manual",
   "how": "评审新增的跨语言模块用的绑定方式",
   "applies": [
    "lang:rust"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-039"
  },
  {
   "id": "RS-040",
   "topic": "13",
   "topicTitle": "Rust",
   "title": "发布库之前用 cargo-semver-checks 检查公开接口的破坏性变化，版本号按 SemVer 递增",
   "level": "soft",
   "check": "auto",
   "how": "发布流程里运行 `cargo semver-checks`，发现破坏性变化而版本号未相应递增时失败",
   "applies": [
    "lang:rust",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/rust/#rs-040"
  },
  {
   "id": "JVM-001",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "生产构建和运行用的 JDK 必须是仍在支持期内的 LTS 版本；非 LTS 版本不作服务端默认",
   "level": "hard",
   "check": "auto",
   "how": "构建插件检查 JDK 版本（Maven Enforcer 的 `requireJavaVersion`，Gradle toolchain 的 `languageVersion`）；CI 打印 `java -version`，与发行版的支持路线图比对",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-001"
  },
  {
   "id": "JVM-002",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "发布构建不得启用 JDK 的预览特性（`--enable-preview`）和孵化模块（`jdk.incubator.*`）",
   "level": "hard",
   "check": "auto",
   "how": "CI 扫描构建文件、启动脚本和容器 entrypoint 中的 `--enable-preview` 和 `jdk.incubator`；发现即失败",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-002"
  },
  {
   "id": "JVM-003",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "编译目标版本用 `--release` 或构建工具的 toolchain 固定，不依赖开发机上恰好装了哪个 JDK",
   "level": "hard",
   "check": "auto",
   "how": "检查 Maven 的 `maven.compiler.release` 或 Toolchains 配置、Gradle 的 `java.toolchain.languageVersion`（Kotlin 用 `jvmToolchain`）；CI 在没有预装其他 JDK 的镜像里构建",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-003"
  },
  {
   "id": "JVM-004",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "部署清单写明 JDK 的发行版、大版本和 CPU 架构；同一产品的构建、测试、生产用同一发行版",
   "level": "soft",
   "check": "semi",
   "how": "评审部署清单或镜像元数据；CI 从运行镜像中读出 `java -version` 的厂商和版本，与清单比对",
   "applies": [
    "lang:jvm",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-004"
  },
  {
   "id": "JVM-005",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "使用 Oracle JDK 前必须核对所用版本当前适用的许可条款；条款要求付费订阅时，要有订阅或换用其他 OpenJDK 发行版",
   "level": "hard",
   "check": "semi",
   "how": "构建镜像和部署清单里扫描 Oracle JDK（`java -version` 输出的厂商）；出现时要求附上许可依据",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-005"
  },
  {
   "id": "JVM-006",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "使用 Maven Wrapper 或 Gradle Wrapper 并入库，CI 和开发者都通过 Wrapper 构建；Gradle Wrapper 设 `distributionSha256Sum`，CI 校验 wrapper jar",
   "level": "hard",
   "check": "auto",
   "how": "检查仓库里有 `mvnw` / `.mvn/wrapper/` 或 `gradlew` / `gradle/wrapper/`；CI 只调用 Wrapper；Gradle 项目在 CI 里跑 wrapper 校验（如 gradle/actions 的 wrapper-validation）",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-006"
  },
  {
   "id": "JVM-007",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "发布构建不得依赖 SNAPSHOT、动态版本（`+`、`latest.release`）或版本范围",
   "level": "hard",
   "check": "auto",
   "how": "Maven 用 Enforcer 的 `requireReleaseDeps` 等规则；Gradle 用 `resolutionStrategy.failOnDynamicVersions()` 和 `failOnChangingVersions()`",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-007"
  },
  {
   "id": "JVM-008",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "Maven 项目的依赖版本集中在 BOM 或 `dependencyManagement`，所有插件显式写死版本",
   "level": "hard",
   "check": "auto",
   "how": "Maven Enforcer 的 `requirePluginVersions`；评审子模块中直接写版本号的依赖",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-008"
  },
  {
   "id": "JVM-009",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "Gradle 项目的依赖和插件版本集中写在版本目录（`gradle/libs.versions.toml`）里，构建脚本只引用别名",
   "level": "soft",
   "check": "semi",
   "how": "评审构建脚本里直接写版本字符串的依赖声明",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-009"
  },
  {
   "id": "JVM-010",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "Gradle 构建的应用开启依赖锁定（`dependencyLocking`）并提交锁文件；锁文件变化在 PR 里评审",
   "level": "hard",
   "check": "auto",
   "how": "检查构建脚本有 `lockAllConfigurations()`、仓库有 `gradle.lockfile`；CI 构建时锁文件与解析结果不一致即失败",
   "applies": [
    "lang:jvm",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-010"
  },
  {
   "id": "JVM-011",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "依赖和插件只能从批准的仓库（通常是内部镜像）获取；Gradle 设 `repositoriesMode` 为 `FAIL_ON_PROJECT_REPOS`，Maven 在 `settings.xml` 用 `mirrorOf` 统一指向内部镜像",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查 `settings.gradle(.kts)` 的 `dependencyResolutionManagement` 与 `pluginManagement`；CI 的 Maven `settings.xml` 由流水线提供，仓库里的 pom 不得自行添加 `<repositories>`",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-011"
  },
  {
   "id": "JVM-012",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "要求断网交付的项目，必须在无外网的 CI 环境里，从空的本地缓存出发、只用内部镜像完成构建和测试",
   "level": "hard",
   "check": "auto",
   "how": "隔离网络的 CI 任务：清空 `~/.m2` 或 `~/.gradle` 缓存，只能访问内部仓库和镜像仓库，运行构建、测试和镜像打包",
   "applies": [
    "lang:jvm",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-012"
  },
  {
   "id": "JVM-013",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "使用 Spring Boot 时，它的 BOM 管理的依赖不单独指定版本；确需覆盖的，写明原因并在升级 Spring Boot 时复查",
   "level": "soft",
   "check": "semi",
   "how": "评审 pom 或构建脚本里覆盖受管依赖版本的写法（Maven 的 `<version>`、属性覆盖，Gradle 的强制版本）",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-013"
  },
  {
   "id": "JVM-014",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "Spring Boot、Spring Cloud（含 Spring Cloud Alibaba 等第三方套件）的版本组合必须在官方兼容表内，不自行搭配",
   "level": "hard",
   "check": "semi",
   "how": "仓库里记录所用组合和对应的官方兼容表链接；升级 PR 对照兼容表评审，并跑完整集成测试",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-014"
  },
  {
   "id": "JVM-015",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "Kotlin 项目使用的 Gradle 版本必须在所用 Kotlin Gradle 插件官方声明的兼容范围内，升级任何一方前先查兼容表",
   "level": "hard",
   "check": "semi",
   "how": "升级 Kotlin 或 Gradle 的 PR 附上兼容表链接；CI 打印 `./gradlew --version` 和 Kotlin 插件版本",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-015"
  },
  {
   "id": "JVM-016",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "生产使用的 Spring Boot 版本线必须在开源支持期内，或者有商业支持；停止支持前完成升级",
   "level": "hard",
   "check": "auto",
   "how": "CI 读取所用 Spring Boot 版本，与官方支持版本表比对；离开开源支持期仍无商业支持时失败",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-016"
  },
  {
   "id": "JVM-017",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "新的 Web 服务默认用 Spring MVC（阻塞模型）；只有端到端都是非阻塞的场景才用 WebFlux",
   "level": "soft",
   "check": "manual",
   "how": "评审技术选型说明",
   "applies": [
    "lang:jvm",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-017"
  },
  {
   "id": "JVM-018",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "使用 Spring Boot 当前大版本时，JSON 用它默认的 Jackson 大版本；需要引入另一个 Jackson 大版本的，写明原因并加序列化契约测试",
   "level": "soft",
   "check": "semi",
   "how": "依赖树检查同时出现两个 Jackson 大版本；评审对应的契约测试",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-018"
  },
  {
   "id": "JVM-019",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "生产环境不得把 Actuator 管理端点暴露到公网；只暴露需要的端点，健康检查分开 liveness 和 readiness",
   "level": "hard",
   "check": "semi",
   "how": "检查 `management.endpoints.web.exposure.include` 和管理端口配置；部署层确认管理端口不对外",
   "applies": [
    "lang:jvm",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-019"
  },
  {
   "id": "JVM-020",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "服务开启优雅停机，并设置每个停机阶段的最长等待时间，小于编排平台给的终止宽限期",
   "level": "soft",
   "check": "semi",
   "how": "检查 `server.shutdown` 和 `spring.lifecycle.timeout-per-shutdown-phase`；集成测试发送 SIGTERM，断言进行中的请求完成",
   "applies": [
    "lang:jvm",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-020"
  },
  {
   "id": "JVM-021",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "启用虚拟线程或 GraalVM 原生镜像前，用真实负载和默认方案做对比，记录结果和回滚开关；连接池、并发数等上限照常设置",
   "level": "soft",
   "check": "manual",
   "how": "评审启用说明：对比数据（吞吐、延迟分位、内存）、已知不兼容项、回滚方式",
   "applies": [
    "lang:jvm",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-021"
  },
  {
   "id": "JVM-022",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "Java 代码用一个格式器（默认 google-java-format）统一格式，通过 Spotless 在 CI 里检查",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `spotlessCheck`（Gradle）或 `spotless:check`（Maven），有差异即失败",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-022"
  },
  {
   "id": "JVM-023",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "Kotlin 代码用 ktlint 格式化和检查（可通过 Spotless 调用），用 detekt 做静态检查；两者都在 CI 里运行",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `ktlintCheck` 或 `spotlessCheck`，以及 `detekt`；有新增问题即失败",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-023"
  },
  {
   "id": "JVM-024",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "Java 项目在格式器之外，至少启用一种查缺陷的静态检查（Error Prone 或 SpotBugs），并在 CI 里阻断新增的错误级问题",
   "level": "soft",
   "check": "auto",
   "how": "CI 编译时启用 Error Prone，或运行 SpotBugs；配置里按规则编号写明开启和关闭的规则",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-024"
  },
  {
   "id": "JVM-025",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "Kotlin 代码不用 `GlobalScope` 启动协程；`!!` 非空断言只在有注释说明为什么不会为空时使用",
   "level": "hard",
   "check": "auto",
   "how": "detekt 启用 `GlobalCoroutineUsage` 和 `UnsafeCallOnNullableType`；例外处用带规则名的 `@Suppress` 并写理由",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-025"
  },
  {
   "id": "JVM-026",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "模块或包之间的依赖方向写成 ArchUnit 测试，与单元测试一起在 CI 里运行",
   "level": "soft",
   "check": "auto",
   "how": "测试代码里有 ArchUnit 规则（如领域包不依赖 Spring、JPA、Web 包；模块间无环），CI 运行",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-026"
  },
  {
   "id": "JVM-027",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "单元测试用 JUnit 平台（Jupiter 编程模型）加 AssertJ 断言；Kotlin 项目可用 Kotest 断言和 MockK",
   "level": "soft",
   "check": "manual",
   "how": "评审新增测试的写法和依赖",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-027"
  },
  {
   "id": "JVM-028",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "涉及数据库、消息队列、缓存的集成测试用 Testcontainers 拉起与生产同款同大版本的组件，不用 H2 等内存库冒充",
   "level": "hard",
   "check": "semi",
   "how": "依赖检查发现测试范围引入 H2、HSQLDB 而生产不是该数据库时要求说明；评审集成测试的依赖启动方式",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-028"
  },
  {
   "id": "JVM-029",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "Mockito / MockK 只替代不可控的外部边界；对外 HTTP 依赖用 WireMock 等 HTTP 模拟，不把数据库和事务 mock 掉",
   "level": "soft",
   "check": "manual",
   "how": "评审测试中被 mock 的对象：Repository、DataSource、事务管理器被 mock 时要说明理由",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-029"
  },
  {
   "id": "JVM-030",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "应用代码只依赖 SLF4J 接口打日志；不用 `System.out`、`System.err` 和 `printStackTrace()`；运行时只保留一个日志实现",
   "level": "hard",
   "check": "auto",
   "how": "ArchUnit 的 `NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS` 等规则或 Checkstyle 禁用这些调用；依赖检查发现多个 SLF4J 绑定时失败",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-030"
  },
  {
   "id": "JVM-031",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "不写空的 `catch`；捕获 `InterruptedException` 后必须恢复中断标志或向上抛出",
   "level": "hard",
   "check": "auto",
   "how": "Error Prone、SpotBugs 或 detekt（`SwallowedException`、`EmptyCatchBlock`）检查；评审 `catch (InterruptedException e)` 的处理",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-031"
  },
  {
   "id": "JVM-032",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "需要关闭的资源（流、连接、语句、结果集）用 try-with-resources 或 Kotlin 的 `use` 管理",
   "level": "hard",
   "check": "auto",
   "how": "SpotBugs 的资源未关闭检查（OBL、ODR 类规则）或 Error Prone 的相关检查；评审手写 `finally { close() }`",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-032"
  },
  {
   "id": "JVM-033",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "不用 Java 原生序列化（`ObjectInputStream`）读取不可信数据；不得不用时设置反序列化过滤器；Jackson 不开启全局多态类型（default typing）",
   "level": "hard",
   "check": "semi",
   "how": "SpotBugs 加 find-sec-bugs 检查 `ObjectInputStream` 和 Jackson default typing；评审 `jdk.serialFilter` 配置",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-033"
  },
  {
   "id": "JVM-034",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "HTTP 客户端（JDK HttpClient、RestClient、Ktor Client 等）必须显式设置连接超时和读取或请求超时",
   "level": "hard",
   "check": "semi",
   "how": "评审客户端的创建代码；集成测试用 WireMock 模拟慢响应，断言在超时内失败",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-034"
  },
  {
   "id": "JVM-035",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "SQL 参数一律用绑定参数（`PreparedStatement`、JdbcClient / JdbcTemplate 参数、JPA 参数、MyBatis 的 `#{}`），不拼接用户输入；MyBatis 的 `${}` 只能接白名单里的标识符",
   "level": "hard",
   "check": "semi",
   "how": "find-sec-bugs 的 SQL 注入检查；扫描 Mapper 文件中的 `${`；评审动态 SQL 的标识符来源",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-035"
  },
  {
   "id": "JVM-036",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "生产环境不得用 Hibernate 自动建表或改表（`ddl-auto` 为 `update`、`create`、`create-drop`）；表结构只通过迁移工具变更",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查生产配置中 `spring.jpa.hibernate.ddl-auto` 只能是 `none` 或 `validate`",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-036"
  },
  {
   "id": "JVM-037",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "一个服务只选一种主要的数据访问方式（JDBC / JdbcClient、JPA、jOOQ、MyBatis 之一）",
   "level": "soft",
   "check": "manual",
   "how": "评审依赖树和数据访问层：出现第二种方式时要求说明",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-037"
  },
  {
   "id": "JVM-038",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "JPA 实体不直接作为接口的请求或响应；用单独的 DTO 或查询投影",
   "level": "soft",
   "check": "semi",
   "how": "ArchUnit 规则：Controller 的方法签名不得出现 `@Entity` 类型",
   "applies": [
    "lang:jvm",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-038"
  },
  {
   "id": "JVM-039",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "使用 openGauss 等「兼容 PostgreSQL」的国产数据库时，确认驱动类名不与 pgJDBC 冲突，并在目标数据库上跑迁移和集成测试",
   "level": "soft",
   "check": "semi",
   "how": "依赖树检查同时存在 pgJDBC 和厂商驱动；在目标数据库实例上跑迁移、增删改查和并发测试",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-039"
  },
  {
   "id": "JVM-040",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "CI 对 JVM 依赖运行漏洞扫描（OWASP Dependency-Check 或 OSV-Scanner），按项目设定的严重级别阻断；断网环境使用内部同步的漏洞库并记录库的更新日期",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 Dependency-Check 的 Maven / Gradle 插件（设 `failBuildOnCVSS`）或 `osv-scanner`；误报写进抑制文件并附理由和到期日",
   "applies": [
    "lang:jvm"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-040"
  },
  {
   "id": "JVM-041",
   "topic": "14",
   "topicTitle": "Java 与 Kotlin（服务端）",
   "title": "供别人引用的库不使用 Spring Boot 的打包插件；对公开 API 做二进制兼容检查（Java 用 japicmp，Kotlin 用 Kotlin 插件的 ABI 校验）",
   "level": "soft",
   "check": "auto",
   "how": "依赖检查库模块没有 `spring-boot-maven-plugin` 或 `org.springframework.boot` 插件；CI 运行 japicmp 或 ABI 校验任务，对比上一个发布版本",
   "applies": [
    "lang:jvm",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/jvm/#jvm-041"
  },
  {
   "id": "CFG-001",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "可执行脚本首行必须写 shebang，且与实际语法一致；用 `#!/bin/sh` 的脚本里不写 Bash 专有语法",
   "level": "hard",
   "check": "auto",
   "how": "ShellCheck 对缺少 shebang 报 SC2148；按 shebang 判定方言后，对 sh 脚本里的 Bash 语法报 SC3xxx 系列警告",
   "applies": [
    "lang:shell"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-001"
  },
  {
   "id": "CFG-002",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "仓库里所有 Shell 脚本（包括没有 `.sh` 后缀的可执行文件）在 CI 上跑 ShellCheck，有告警即失败",
   "level": "hard",
   "check": "auto",
   "how": "用 `shfmt -f .` 按后缀和 shebang 列出全部 Shell 文件，交给 `shellcheck` 检查",
   "applies": [
    "lang:shell"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-002"
  },
  {
   "id": "CFG-003",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "Shell 脚本用 shfmt 统一格式，CI 用 `shfmt -d` 检查；缩进等选项写在 `.editorconfig` 里",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `shfmt -d .`，有差异即失败",
   "applies": [
    "lang:shell"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-003"
  },
  {
   "id": "CFG-004",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "变量展开和命令替换一律加双引号；传多个参数用 `\"$@\"` 或数组，不拼成一个字符串",
   "level": "hard",
   "check": "auto",
   "how": "ShellCheck 的 SC2086（未加引号的展开）、SC2068（未加引号的 `$@`）等规则",
   "applies": [
    "lang:shell"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-004"
  },
  {
   "id": "CFG-005",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "Bash 脚本开头默认写 `set -euo pipefail`；预期可能失败的命令用 `if` 或显式检查退出码，不把 `-e` 当异常处理",
   "level": "soft",
   "check": "semi",
   "how": "脚本扫描检查开头的 set 行；评审预期失败的命令是否显式处理",
   "applies": [
    "lang:shell"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-005"
  },
  {
   "id": "CFG-006",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "不可信输入不得进入 `eval`、`bash -c`、`sh -c` 或拼接出的命令字符串；命令和参数分开传",
   "level": "hard",
   "check": "semi",
   "how": "扫描 `eval`、`-c` 调用作为候选，评审其参数来源",
   "applies": [
    "lang:shell"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-006"
  },
  {
   "id": "CFG-007",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "临时文件和目录用 `mktemp` 创建并用 `trap` 清理；`rm -rf` 用到的路径变量先确认非空且在受控目录内",
   "level": "hard",
   "check": "semi",
   "how": "ShellCheck 的 SC2115 提示用 `${var:?}` 防止变量为空时删到根目录；评审删除路径的来源",
   "applies": [
    "lang:shell"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-007"
  },
  {
   "id": "CFG-008",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "错误信息写到标准错误，失败返回非零退出码；会改外部状态的脚本提供预演（dry-run）或先预检再执行",
   "level": "soft",
   "check": "manual",
   "how": "评审时看错误输出位置、退出码和危险操作前的预检",
   "applies": [
    "lang:shell"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-008"
  },
  {
   "id": "CFG-009",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "脚本出现复杂分支、状态机、并发、重试或要读写 JSON/YAML 时，改用项目主语言写",
   "level": "soft",
   "check": "manual",
   "how": "评审新增或大幅修改的脚本时判断是否还适合用 Shell",
   "applies": [
    "lang:shell"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-009"
  },
  {
   "id": "CFG-010",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "PowerShell 脚本在 CI 上跑 PSScriptAnalyzer，不用 ShellCheck 查 `.ps1`",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `Invoke-ScriptAnalyzer -Recurse`，有错误级问题即失败",
   "applies": [
    "lang:shell"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-010"
  },
  {
   "id": "CFG-011",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "仓库根目录提供一个任务入口（justfile、Taskfile.yml、Makefile 或 package.json 脚本），能列出全部任务及说明",
   "level": "soft",
   "check": "semi",
   "how": "检查根目录存在任务入口，`just --list`、`task --list` 等能列出任务；贡献说明里写明所需工具",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-011"
  },
  {
   "id": "CFG-012",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "适用的任务统一用这些名字：help、dev、test、lint、format、check、build、clean；不适用的不建空壳",
   "level": "soft",
   "check": "semi",
   "how": "脚本列出入口里的任务名，与约定名单比对",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-012"
  },
  {
   "id": "CFG-013",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "`check`、`lint`、`test` 任务不得修改工作区里的文件",
   "level": "hard",
   "check": "auto",
   "how": "CI 跑完 `check` 后执行 `git status --porcelain`，输出非空即失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-013"
  },
  {
   "id": "CFG-014",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "任务失败必须返回非零退出码；不用 `|| true`、Make 的 `-` 前缀、Task 的 `ignore_error`、CI 的 `allow_failure` 吞掉检查失败",
   "level": "hard",
   "check": "semi",
   "how": "扫描任务文件和 CI 配置里的这些写法作为候选，逐个确认有理由且不是必过检查",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-014"
  },
  {
   "id": "CFG-015",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "部署、发布、重置数据这类有外部副作用的任务单独命名（如 `deploy-staging`、`reset-db`），不挂在默认任务、check 或 test 之下",
   "level": "soft",
   "check": "manual",
   "how": "评审任务入口时检查默认任务和检查类任务的依赖链",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-015"
  },
  {
   "id": "CFG-016",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "Makefile 里不对应文件的目标写进 `.PHONY`；配方用到 Bash 语法时显式设置 `SHELL` 和 `.SHELLFLAGS`",
   "level": "soft",
   "check": "semi",
   "how": "评审 Makefile；检查非文件目标是否在 `.PHONY` 中",
   "applies": [
    "lang:shell"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-016"
  },
  {
   "id": "CFG-017",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "按消费者选配置格式：机器之间交换用 JSON，已有生态约定的（CI、容器编排）用 YAML，人工编辑的应用配置可用 TOML；不为统一而强行转换",
   "level": "soft",
   "check": "manual",
   "how": "评审新增配置文件时确认格式是消费者原生支持的",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-017"
  },
  {
   "id": "CFG-018",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "机器读取、结构固定的配置文件配一份入库的 JSON Schema，CI 用 check-jsonschema 校验；校验时使用本地 Schema，不联网拉取",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `check-jsonschema --schemafile schemas/xxx.json <配置文件>`",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-018"
  },
  {
   "id": "CFG-019",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "YAML 和 JSON 配置不得出现重复键",
   "level": "hard",
   "check": "auto",
   "how": "YAML 用 yamllint 的 `key-duplicates` 规则（默认开启）；JSON 用能检出重复键的工具，如 pre-commit-hooks 的 `check-json` 或 Biome 的 `noDuplicateObjectKeys`",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-019"
  },
  {
   "id": "CFG-020",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "YAML 里会被自动转换类型的值加引号，如 yes/no/on/off、像版本号的 `1.10`、带前导零的数字、`12:30` 这类时间",
   "level": "hard",
   "check": "semi",
   "how": "yamllint 的 `truthy` 和 `octal-values` 规则调成 error 级别；像版本号的浮点值靠评审和反序列化测试",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-020"
  },
  {
   "id": "CFG-021",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "YAML 文件在 CI 上跑 yamllint，配置文件入库；与格式器冲突的排版规则关掉，只留格式器管不到的规则",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `yamllint -s .`（`-s` 让告警也失败）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-021"
  },
  {
   "id": "CFG-022",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "JSON 和 YAML 用同一个格式器（默认 Prettier），CI 用检查模式验证",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `prettier --check \"**/*.{json,yml,yaml}\"`",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-022"
  },
  {
   "id": "CFG-023",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "TOML 文件用一个格式化和检查工具（默认 Tombi），CI 用检查模式验证",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行该工具的检查模式（如 `tombi format --check` 和 `tombi lint`）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-023"
  },
  {
   "id": "CFG-024",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "`.env` 等本地环境文件写进 `.gitignore`，不入库；入库的 `.env.example` 只列变量名、说明和非敏感默认值",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查版本库里没有除 `.env.example` 以外的 `.env`、`.env.*` 文件，并对仓库跑密钥扫描（工具见 25）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-024"
  },
  {
   "id": "CFG-025",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "环境变量名只用大写字母、数字和下划线，并带应用或服务前缀，如 `ORDER_SVC_DB_URL`",
   "level": "soft",
   "check": "semi",
   "how": "脚本检查 `.env.example` 和配置定义里的变量名格式",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-025"
  },
  {
   "id": "CFG-026",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": ".env 文件只用于本地开发；生产环境由部署平台或密钥服务注入，不把 .env 打进镜像",
   "level": "soft",
   "check": "manual",
   "how": "评审部署配置和 Dockerfile，确认没有复制 .env",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-026"
  },
  {
   "id": "CFG-027",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "Dockerfile 在 CI 上跑 hadolint 和 `docker build --check`，并实际构建一次",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `hadolint Dockerfile` 和 `docker build --check .`，再执行真实构建",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-027"
  },
  {
   "id": "CFG-028",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "正式发布的镜像，FROM 不用 `latest` 或不带标签的基础镜像；用标签加 digest 固定，由依赖更新机器人提 PR 升级",
   "level": "hard",
   "check": "auto",
   "how": "hadolint 的 DL3006（未带标签）、DL3007（用了 latest）；另用脚本检查 FROM 行是否带 `@sha256:`",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-028"
  },
  {
   "id": "CFG-029",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "最终镜像以非 root 用户运行；确实需要 root 的，写明原因和运行时的限制措施",
   "level": "hard",
   "check": "auto",
   "how": "hadolint 的 DL3002（最后的 USER 是 root）；构建后运行 `id` 确认",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-029"
  },
  {
   "id": "CFG-030",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "构建时需要的凭据用 BuildKit 的 secret 或 ssh 挂载传入，不用 ARG、ENV 或 COPY",
   "level": "hard",
   "check": "auto",
   "how": "`docker build --check` 的 SecretsUsedInArgOrEnv 检查；对 Dockerfile 跑密钥扫描",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-030"
  },
  {
   "id": "CFG-031",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "有 Dockerfile 的目录必须有 `.dockerignore`，至少排除 `.git`、`.env*`、密钥文件、依赖和构建缓存目录",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查 `.dockerignore` 存在并包含这些条目",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-031"
  },
  {
   "id": "CFG-032",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "用多阶段构建，运行阶段只拷贝运行所需的文件；先拷依赖清单和锁文件安装依赖，再拷源码",
   "level": "soft",
   "check": "semi",
   "how": "评审 Dockerfile 的阶段划分和 COPY 顺序；对比镜像体积",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-032"
  },
  {
   "id": "CFG-033",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "系统包安装和清理缓存放在同一个 RUN 里；CMD 和 ENTRYPOINT 用 JSON 数组写法",
   "level": "soft",
   "check": "auto",
   "how": "hadolint 的 DL3009（未清 apt 列表）、DL3015（未加 `--no-install-recommends`）；`docker build --check` 的 JSONArgsRecommended",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-033"
  },
  {
   "id": "CFG-034",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "仓库里的 SQL 文件用 SQLFluff 检查和格式化，`.sqlfluff` 入库并写明目标方言",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `sqlfluff lint`；`.sqlfluff` 里必须有 `dialect` 设置",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-034"
  },
  {
   "id": "CFG-035",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "业务查询写明要取的列，不用 `SELECT *`；`EXISTS (SELECT 1 ...)` 这类不取列的写法除外",
   "level": "soft",
   "check": "auto",
   "how": "在 `.sqlfluff` 中启用 AM04（结果列数不确定）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-035"
  },
  {
   "id": "CFG-036",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "GitHub Actions 的 workflow 文件在 CI 上跑 actionlint 和 zizmor；GitLab CI 配置用官方 CI Lint 校验",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `actionlint` 和 `zizmor .github/workflows/`；GitLab 调用项目的 CI Lint 接口",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-036"
  },
  {
   "id": "CFG-037",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "第三方 Action 和可复用 workflow 锁到完整的提交 SHA（或使用平台提供的 workflow 依赖锁文件），不只写 `@v4`、`@main`",
   "level": "hard",
   "check": "auto",
   "how": "zizmor 的 `unpinned-uses` 检查",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-037"
  },
  {
   "id": "CFG-038",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "workflow 显式声明最小的 `permissions`，只给需要更高权限的 job 单独提升",
   "level": "hard",
   "check": "auto",
   "how": "zizmor 的 `excessive-permissions` 检查",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-038"
  },
  {
   "id": "CFG-039",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "PR 标题、分支名、提交信息等不可信的事件文本不直接写进 `run:` 脚本，先放进环境变量再引用",
   "level": "hard",
   "check": "auto",
   "how": "zizmor 的 `template-injection` 检查",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-039"
  },
  {
   "id": "CFG-040",
   "topic": "15",
   "topicTitle": "脚本与配置语言",
   "title": "Cron 表达式旁写明由哪个调度器执行、时区和字段数；改动重要的定时任务时，用目标调度器列出接下来的若干次触发时间核对",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查注释和触发时间列表",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/scripts-config/#cfg-040"
  },
  {
   "id": "API-001",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "普通 HTTP 接口默认用 REST + OpenAPI；改用 GraphQL、gRPC/Connect 或 WebSocket 时，把客观理由写进设计记录",
   "level": "soft",
   "check": "manual",
   "how": "评审新服务的设计记录（ADR），看非 REST 方案是否写了需求依据和取舍",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-001"
  },
  {
   "id": "API-002",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "对外 HTTP 接口必须有放在仓库里的 OpenAPI 契约文件，接口改动与契约改动在同一个 PR 里提交",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查契约文件存在；用基于 schema 的测试（见 04）验证实现的响应符合契约",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-002"
  },
  {
   "id": "API-003",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "每个操作必须有唯一且稳定的 operationId，每个响应写明状态码、Content-Type 和 schema，并给出成功和错误示例",
   "level": "hard",
   "check": "auto",
   "how": "Spectral 规则集启用 operationId 唯一、响应必须有 schema 和示例等规则，CI 中以错误级别运行",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-003"
  },
  {
   "id": "API-004",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "契约 lint 规则集放在仓库里，CI 对每次契约改动运行，错误级别的问题阻断合并",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `spectral lint`（或所选工具），检查退出码",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-004"
  },
  {
   "id": "API-005",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "OpenAPI 默认用 3.1；要用 3.2 的新特性，先确认所用的 lint、diff、生成器和网关都支持",
   "level": "soft",
   "check": "semi",
   "how": "升级前在 CI 里用全部工具链跑一遍 3.2 契约，并编译生成的客户端",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-005"
  },
  {
   "id": "API-006",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "按业务资源和动作建模接口，不直接暴露数据库表名、内部函数名和内部枚举",
   "level": "soft",
   "check": "manual",
   "how": "评审新接口时对照数据模型，看路径和字段是否只是表结构的照搬",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-006"
  },
  {
   "id": "API-007",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "GET、HEAD 请求不得产生业务副作用；不能归为标准方法的业务动作用 POST，并在契约里写明副作用和是否幂等",
   "level": "hard",
   "check": "semi",
   "how": "评审时检查 GET 处理函数是否写库或调用有副作用的服务；契约里自定义动作必须有副作用说明",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-007"
  },
  {
   "id": "API-008",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "同一个接口域里的 JSON 字段、查询参数和错误码各只用一种命名风格，并用 lint 强制",
   "level": "hard",
   "check": "auto",
   "how": "Spectral 的 casing 规则检查 schema 属性名和参数名",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-008"
  },
  {
   "id": "API-009",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "时间字段用 RFC 3339 格式并带时区偏移（推荐 UTC），在 schema 里标 `format: date-time`",
   "level": "soft",
   "check": "auto",
   "how": "Spectral 规则检查名称像时间的字段是否声明了 date-time 格式",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-009"
  },
  {
   "id": "API-010",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "返回列表的接口必须分页：`limit` 有默认值和上限，排序确定并以唯一字段兜底",
   "level": "hard",
   "check": "semi",
   "how": "lint 检查列表操作有分页参数和上限；测试用排序字段相同的数据验证翻页不重不漏",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-010"
  },
  {
   "id": "API-011",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "游标对调用方不透明，绑定生成它的查询条件和调用者；条件不一致时拒绝，不承诺快照一致",
   "level": "soft",
   "check": "semi",
   "how": "测试篡改游标、换过滤条件或换用户复用游标，应返回 400；契约文档写明翻页期间数据增删的行为",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-011"
  },
  {
   "id": "API-012",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "过滤、排序、字段选择、展开参数只接受白名单字段，不允许传 SQL 片段或任意表达式",
   "level": "hard",
   "check": "semi",
   "how": "契约里这些参数用枚举声明；测试传非白名单字段应返回 400",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-012"
  },
  {
   "id": "API-013",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "批量操作在契约里写明是否全有或全无、逐项结果的格式和幂等粒度",
   "level": "soft",
   "check": "manual",
   "how": "评审批量接口的契约，看是否有逐项状态字段和部分失败的说明",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-013"
  },
  {
   "id": "API-014",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "可能超时的长任务返回 202 和一个任务资源，可查询状态、终态结果和失败原因，并写明取消方式和结果保留期",
   "level": "soft",
   "check": "semi",
   "how": "端到端测试覆盖任务提交、查询、完成、失败、取消",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-014"
  },
  {
   "id": "API-015",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "4xx 和 5xx 响应使用 RFC 9457 格式（`application/problem+json`），`status` 与 HTTP 状态码一致；不用 200 包装失败",
   "level": "hard",
   "check": "auto",
   "how": "lint 检查所有错误响应引用统一的 Problem schema 和媒体类型；接口测试校验错误响应体",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-015"
  },
  {
   "id": "API-016",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "程序只按 `type` 或 `code` 判断错误，不解析 `title` 和 `detail`；错误码登记在错误目录里，发布后不改含义",
   "level": "hard",
   "check": "semi",
   "how": "契约里 `code` 用枚举并与错误目录比对；评审客户端代码时查找对 `detail` 文本的匹配",
   "applies": [
    "kind:service",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-016"
  },
  {
   "id": "API-017",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "错误响应不得包含堆栈、SQL、内部主机名、令牌和个人信息；`instance` 只用作追踪标识",
   "level": "hard",
   "check": "semi",
   "how": "测试触发各类服务端异常，扫描错误响应体中的堆栈和 SQL 特征",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-017"
  },
  {
   "id": "API-018",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "429 和 503 响应带 `Retry-After`；`retryable` 只表示原请求可以原样重放，写操作是否能重试还要看幂等策略",
   "level": "soft",
   "check": "semi",
   "how": "故障测试检查限流和降级响应的响应头；评审错误目录里 `retryable` 的取值",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-018"
  },
  {
   "id": "API-019",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "有副作用、且调用方可能重试的 POST 操作必须在契约里声明幂等方式：Idempotency-Key 或客户端自定 ID + PUT",
   "level": "hard",
   "check": "semi",
   "how": "lint 检查非幂等方法是否声明了 `Idempotency-Key` 请求头或说明了替代方式；评审支付、下单、发消息等操作",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-019"
  },
  {
   "id": "API-020",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "使用 Idempotency-Key 时，缺键（且要求必填）返回 400，同键不同请求体返回 422，第一次请求仍在处理返回 409；同键同请求体已完成的，返回第一次的结果",
   "level": "hard",
   "check": "auto",
   "how": "契约测试逐一构造这四种情况并校验状态码和响应体",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-020"
  },
  {
   "id": "API-021",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "幂等键的作用域绑定调用者和操作，并记录请求体指纹；幂等记录与业务变更一起持久化，有效期写进契约",
   "level": "hard",
   "check": "semi",
   "how": "并发重复请求测试和进程崩溃测试，验证只执行一次；评审幂等存储是否持久化",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-021"
  },
  {
   "id": "API-022",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "可能丢失更新的写操作用强 ETag + If-Match 做前置条件：不匹配返回 412，要求带前置条件却没带返回 428，业务状态冲突返回 409；弱 ETag 不用于 If-Match",
   "level": "hard",
   "check": "auto",
   "how": "双客户端竞争测试：两个客户端用同一个 ETag 先后更新，后者必须得到 412",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-022"
  },
  {
   "id": "API-023",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "CI 用 oasdiff 对比上一个发布版本的契约；检测到破坏性变更时阻断，除非走新主版本或附上经批准的迁移计划",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行 `oasdiff breaking <基线契约> <新契约>` 并按退出码阻断；豁免要在 PR 里写明并经负责人批准",
   "applies": [
    "kind:service",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-023"
  },
  {
   "id": "API-024",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "每个接口域只选一种版本策略并写进接口说明；默认用路径主版本（如 `/v1`）",
   "level": "soft",
   "check": "manual",
   "how": "评审接口说明里是否写明版本策略、新版本发布后旧版本的支持期",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-024"
  },
  {
   "id": "API-025",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "公开接口废弃时，发布迁移说明，并在响应里带 `Deprecation` 头，确定下线日期后再带 `Sunset` 头",
   "level": "hard",
   "check": "auto",
   "how": "契约测试检查被标为 deprecated 的操作响应带这两个头",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-025"
  },
  {
   "id": "API-026",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "对外发布的事件用 CloudEvents 信封，`type` 稳定且带业务版本，每个 `type` 有独立 schema，并在事件目录里登记",
   "level": "soft",
   "check": "semi",
   "how": "CI 用 JSON Schema 校验事件样例；事件目录（可用 AsyncAPI 描述）与代码里的事件类型比对",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-026"
  },
  {
   "id": "API-027",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "事件按至少一次投递设计：重发时保持同一个 `(source, id)`，消费方据此去重；写明顺序范围，不承诺全局有序和恰好一次",
   "level": "hard",
   "check": "semi",
   "how": "测试重复投递和乱序投递，验证消费方结果不变；评审事件目录里的顺序和投递保证说明",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-027"
  },
  {
   "id": "API-028",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "对外发布的 webhook 按 Standard Webhooks 签名：带 `webhook-id`、`webhook-timestamp`、`webhook-signature`，对「id.时间戳.原始请求体」签名，每个接收端单独密钥，轮换期间同时附新旧两个签名",
   "level": "hard",
   "check": "auto",
   "how": "用 Standard Webhooks 官方验签库写测试，验证签名可通过、篡改请求体或时间戳后失败、轮换期间新旧密钥都能验证",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-028"
  },
  {
   "id": "API-029",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "webhook 投递失败时按指数退避加随机抖动重试，有总时长上限；持续失败后停用接收端并通知对方，提供投递记录和手动重放",
   "level": "soft",
   "check": "semi",
   "how": "故障注入测试：接收端返回 5xx、超时、410，检查重试节奏、停用和重放",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-029"
  },
  {
   "id": "API-030",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "webhook 地址由用户填写时，发送前和每次跟随重定向时都校验目标地址，禁止回环、内网和云元数据地址",
   "level": "hard",
   "check": "auto",
   "how": "SSRF 测试用例：回环地址、内网段、元数据地址、指向内网的重定向和 DNS 重绑定都必须被拒绝",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-030"
  },
  {
   "id": "API-031",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "承诺断线可续传的 SSE 流，每条事件带 `id`，服务端从持久化的事件记录按 `Last-Event-ID` 续传；超出保留期时明确告知调用方重新同步",
   "level": "soft",
   "check": "semi",
   "how": "端到端测试断网重连、服务端重启、游标过期三种情况",
   "applies": [
    "kind:service",
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-031"
  },
  {
   "id": "API-032",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "MCP 工具按用户任务设计，不把每个 REST 接口自动转成工具；每个工具有清楚的名称、描述和输入 schema，结构化输出声明输出 schema",
   "level": "soft",
   "check": "semi",
   "how": "静态检查工具清单：每个工具有描述、输入 schema 禁止多余字段；用固定任务集评测工具被选中和调用的正确率（见 24）",
   "applies": [
    "kind:service",
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-032"
  },
  {
   "id": "API-033",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "MCP 工具执行中的业务错误放在工具结果里并标 `isError`；只有协议层的错误（未知工具、参数不合 schema 等）用 JSON-RPC 错误",
   "level": "hard",
   "check": "auto",
   "how": "协议测试：构造业务失败和协议错误两类请求，检查返回形式",
   "applies": [
    "kind:service",
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-033"
  },
  {
   "id": "API-034",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "用官方 SDK 实现 MCP 协议层；声明支持哪些协议版本，并为每个声明支持的版本跑互通测试",
   "level": "hard",
   "check": "auto",
   "how": "CI 用各个声明版本的客户端连接服务，跑工具列表和调用测试",
   "applies": [
    "kind:service",
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-034"
  },
  {
   "id": "API-035",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "通过 HTTP 提供、需要授权的 MCP 服务，按 OAuth 2.1 资源服务器实现：提供受保护资源元数据，校验令牌是签发给本服务的，不接受也不转发其他令牌，令牌不放在 URL 里",
   "level": "hard",
   "check": "auto",
   "how": "测试用发给其他服务的令牌、过期令牌、放在查询参数里的令牌访问，都必须返回 401；检查受保护资源元数据端点可访问",
   "applies": [
    "kind:service",
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-035"
  },
  {
   "id": "API-036",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "MCP 客户端或宿主不信任第三方服务自报的工具注解（只读、破坏性、幂等等）；副作用和权限由宿主自己的策略决定",
   "level": "hard",
   "check": "manual",
   "how": "评审宿主的权限策略，确认没有根据第三方注解自动放行写操作",
   "applies": [
    "kind:llm",
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-036"
  },
  {
   "id": "API-037",
   "topic": "16",
   "topicTitle": "接口契约与事件",
   "title": "命令行工具提供 `--json` 模式（标准输出只有一个合法 JSON，诊断信息走标准错误），公开稳定的退出码表，并支持非交互运行",
   "level": "soft",
   "check": "auto",
   "how": "CLI 测试用 `jq` 解析 `--json` 输出；在无终端环境下运行，检查不会等待输入",
   "applies": [
    "kind:cli"
   ],
   "url": "https://plumbrule.dev/topics/api/#api-037"
  },
  {
   "id": "DB-001",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "每张有业务身份的表必须有主键；纯关联表可以用组合主键",
   "level": "hard",
   "check": "auto",
   "how": "CI 查询系统目录，列出没有主键的表，与登记的例外清单比对",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-001"
  },
  {
   "id": "DB-002",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "表、列、约束、索引用小写加下划线命名；可变的业务编号不当技术主键，另建唯一约束",
   "level": "soft",
   "check": "semi",
   "how": "用迁移 lint 或自写脚本检查命名；评审时看主键是否用了订单号这类业务编号",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-002"
  },
  {
   "id": "DB-003",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "对外暴露或跨系统生成的 ID 用 UUIDv7，单库内部的高写入表可用自增 bigint；任何 ID 都不当作访问凭证",
   "level": "soft",
   "check": "semi",
   "how": "评审表结构时看主键类型；搜索代码里是否有「知道 ID 就能访问」的接口",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-003"
  },
  {
   "id": "DB-004",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "表示某个时刻的列用带时区的时间类型（PostgreSQL 用 timestamptz）；需要本地业务时间的，另存 IANA 时区名",
   "level": "hard",
   "check": "semi",
   "how": "迁移 lint 或脚本列出不带时区的时间列，评审确认是否属于「本地日期时间」这类例外",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-004"
  },
  {
   "id": "DB-005",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "金额和要求精确的数值用 numeric/decimal 或约定单位的整数（如「分」），不用浮点类型",
   "level": "hard",
   "check": "semi",
   "how": "迁移 lint 或脚本列出 real、double precision、float 类型的列，评审确认不是金额或计量值",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-005"
  },
  {
   "id": "DB-006",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "非空、唯一、外键、检查约束在数据库里表达；要去掉外键等约束，写明理由并登记",
   "level": "soft",
   "check": "semi",
   "how": "评审迁移时看新表是否声明了约束；去掉约束的迁移要求 PR 里附理由",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-006"
  },
  {
   "id": "DB-007",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "先定删除语义：默认真删除；确有恢复或留存需要才用软删除，并用部分唯一索引处理唯一约束",
   "level": "soft",
   "check": "manual",
   "how": "设计评审时确认每类实体的删除方式，以及对象存储、搜索、向量副本、下游事件的清理责任",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-007"
  },
  {
   "id": "DB-008",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "多个租户共用一张表时，唯一约束、外键和常用索引都包含租户列",
   "level": "hard",
   "check": "semi",
   "how": "脚本列出带租户列的表中不含租户列的唯一约束和外键；评审确认",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-008"
  },
  {
   "id": "DB-009",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "使用 PostgreSQL 行级安全（RLS）时，应用连接用的角色不得是表所有者、超级用户或带 BYPASSRLS 属性，并对表开启 FORCE ROW LEVEL SECURITY",
   "level": "hard",
   "check": "auto",
   "how": "CI 查询 pg_roles 和 pg_class，检查应用角色的属性和表的 relforcerowsecurity；再用跨租户负例测试验证",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-009"
  },
  {
   "id": "DB-010",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "分区只在有明确的生命周期、查询或数据量需求时使用；用分区就要自动预建未来分区并有清理流程",
   "level": "soft",
   "check": "semi",
   "how": "监控默认分区的行数增长；评审分区方案时看清理和归档步骤",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-010"
  },
  {
   "id": "DB-011",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "生产库的表结构只通过版本化迁移变更；迁移作为单独的发布步骤用专用角色执行，不在应用启动时自动同步或每个实例都跑",
   "level": "hard",
   "check": "semi",
   "how": "搜索生产代码中 ORM 的自动同步或建表调用；检查部署流程里迁移是单独的步骤；业务角色没有 DDL 权限",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-011"
  },
  {
   "id": "DB-012",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "不兼容的表结构变更按扩展-收缩分步做：先新增、再回填和切换、最后删除旧结构；每一步都要让当前版本和上一版本的应用能同时运行",
   "level": "hard",
   "check": "semi",
   "how": "CI 用上一版本的应用代码跑新迁移后的库做冒烟测试；评审时检查删列、改名、改类型是否拆成了多次发布",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-012"
  },
  {
   "id": "DB-013",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "回滚以向前修复为主；删除数据的迁移标明不可逆，不靠 down 脚本声称可以恢复",
   "level": "hard",
   "check": "manual",
   "how": "PR 模板要求写明迁移是否可逆、出问题时的应用回退方式和数据修复方案；评审确认",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-013"
  },
  {
   "id": "DB-014",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "迁移在与生产同款、同大版本的数据库上验证：既从空库跑，也在上一版本的脱敏数据快照上跑",
   "level": "hard",
   "check": "semi",
   "how": "CI 起与生产同版本的数据库容器跑迁移；发布前在脱敏快照上演练并记录耗时",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-014"
  },
  {
   "id": "DB-015",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "PostgreSQL 迁移按安全写法：DDL 前设 lock_timeout；大表建索引用 CREATE INDEX CONCURRENTLY 且不放在事务里，完成后检查索引有效；加约束先 NOT VALID 再单独 VALIDATE",
   "level": "hard",
   "check": "auto",
   "how": "CI 对迁移文件跑 Squawk；迁移执行后查询 pg_index.indisvalid",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-015"
  },
  {
   "id": "DB-016",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "大表变更的 PR 写明锁级别、是否重写全表、预计耗时和执行窗口；lint 管不到的风险由人审查",
   "level": "soft",
   "check": "manual",
   "how": "PR 模板固定这些栏目；评审者对照表大小和流量确认",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-016"
  },
  {
   "id": "DB-017",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "数据回填分批、幂等、可暂停续跑，不和 DDL 放在同一个事务里",
   "level": "hard",
   "check": "semi",
   "how": "集成测试中途中断回填再重启，检查结果正确且不重复处理",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-017"
  },
  {
   "id": "DB-018",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "MySQL 迁移不套用 PostgreSQL 的事务 DDL 假设；在线变更显式指定 ALGORITHM 和 LOCK，并在同版本实例上验证",
   "level": "hard",
   "check": "semi",
   "how": "迁移 lint 检查 ALTER 语句是否写了 ALGORITHM/LOCK；在同版本 MySQL 上演练，包括中途中断",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-018"
  },
  {
   "id": "DB-019",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "「兼容 PostgreSQL」或「兼容 MySQL」的数据库（如 openGauss、金仓、TiDB、OceanBase）单独跑方言测试，不假设与原版等价",
   "level": "hard",
   "check": "semi",
   "how": "CI 或验收环境对每种目标库跑同一套契约测试：类型、时区、唯一与空值、upsert、锁等待、SKIP LOCKED、隔离级别、DDL、错误码",
   "applies": [
    "all",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-019"
  },
  {
   "id": "DB-020",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "事务边界在业务用例层划定；事务内不调用大模型、邮件、远程 HTTP 等外部服务",
   "level": "hard",
   "check": "semi",
   "how": "评审事务代码块内的调用；监控长事务和「事务中空闲」的连接",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-020"
  },
  {
   "id": "DB-021",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "按用例显式选隔离级别；默认的读已提交级别下「先读后写」有竞态的，用行锁、乐观锁或更高隔离级别保护",
   "level": "soft",
   "check": "manual",
   "how": "评审「先查再改」的代码路径，看用了哪种保护；用并发测试复现竞态",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-021"
  },
  {
   "id": "DB-022",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "使用可重复读或可串行化级别时，序列化失败（40001）和死锁（40P01）要整体重试整个事务，有次数上限和退避；唯一冲突（23505）按业务处理，不盲目重试",
   "level": "hard",
   "check": "semi",
   "how": "检查事务封装里对 SQLSTATE 的分类；并发测试制造序列化失败，确认会重试且最终结果正确",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-022"
  },
  {
   "id": "DB-023",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "乐观锁用版本列：更新时带上期望版本并递增，检查影响行数；冲突时返回业务错误，不自动覆盖",
   "level": "hard",
   "check": "semi",
   "how": "并发更新测试：两次同时修改同一行，一方必须收到冲突错误",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-023"
  },
  {
   "id": "DB-024",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "SKIP LOCKED 只用于多个消费者领取队列式表里的任务，不用于需要一致视图的普通查询",
   "level": "hard",
   "check": "semi",
   "how": "搜索代码中的 SKIP LOCKED，确认都在任务领取路径上",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-024"
  },
  {
   "id": "DB-025",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "多个 worker 领取任务时用租约：用数据库时钟记录到期时间，到期可被回收；每次领取递增一个 fencing token，完成、续约和写结果时都校验它",
   "level": "hard",
   "check": "semi",
   "how": "故障测试：让持有租约的 worker 暂停到租约过期，另一个 worker 接手后，原 worker 恢复时的写入必须被拒绝",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-025"
  },
  {
   "id": "DB-026",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "业务写入和待发事件需要原子时，outbox 记录与业务变更在同一个事务里提交",
   "level": "hard",
   "check": "semi",
   "how": "故障注入测试：业务事务回滚时 outbox 不能有记录；提交后、投递前中继崩溃，重启后事件仍会发出",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-026"
  },
  {
   "id": "DB-027",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "outbox 中继按至少一次投递设计：带领取租约、重试次数上限、失败转人工处理、已发送记录定期清理；消费者按事件 ID 幂等",
   "level": "hard",
   "check": "semi",
   "how": "测试投递后、标记已发送前中继崩溃的情况，确认消费者收到重复事件也只处理一次",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-027"
  },
  {
   "id": "DB-028",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "幂等表以「租户 + 操作 + 幂等键」做唯一约束并保存请求指纹；处理结果与业务变更在同一个事务里提交",
   "level": "hard",
   "check": "semi",
   "how": "并发测试：同一个键同时发两次只执行一次；同一个键换了请求内容，按 16 的约定返回冲突",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-028"
  },
  {
   "id": "DB-029",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "连接预算：各服务「连接池上限 × 实例数」加上管理和迁移连接，总和不超过数据库允许的连接数",
   "level": "hard",
   "check": "semi",
   "how": "部署配置检查脚本按最大扩容实例数计算总和并与数据库上限比较；压测时观察连接数",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-029"
  },
  {
   "id": "DB-030",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "启用 PgBouncer 这类事务级连接池前，先测会话状态、预编译语句、临时表、LISTEN/NOTIFY 和 RLS 租户上下文",
   "level": "soft",
   "check": "semi",
   "how": "连接池集成测试：两个请求交替复用同一连接，检查设置和租户上下文不会串",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-030"
  },
  {
   "id": "DB-031",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "新增关键查询或改索引时，附上在有代表性数据量上的执行计划；每个索引对应具体查询，不给每列都建索引",
   "level": "soft",
   "check": "semi",
   "how": "PR 附 `EXPLAIN (ANALYZE, BUFFERS)` 结果；定期用 pg_stat_statements 找慢查询，查未使用的索引",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-031"
  },
  {
   "id": "DB-032",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "有读副本时，为每类查询写明一致性要求；读自己刚写的数据、授权判断、事务内的读一律走主库",
   "level": "hard",
   "check": "semi",
   "how": "评审数据访问层的路由规则；一致性测试：写入后立刻读，不能读到旧值",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-032"
  },
  {
   "id": "DB-033",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "生产数据有书面的恢复目标（RPO/RTO）、保留期、负责人和恢复手册；备份范围覆盖数据库、对象存储、配置和密钥恢复材料",
   "level": "hard",
   "check": "manual",
   "how": "发布前检查恢复手册是否存在并列明这些项；演练时按手册执行",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-033"
  },
  {
   "id": "DB-034",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "主从复制和存储快照不是备份；需要时间点恢复的，用基础备份加连续 WAL 归档（MySQL 用基础备份加 binlog），不能只靠 pg_dump",
   "level": "hard",
   "check": "semi",
   "how": "检查备份工具配置包含 WAL 归档；恢复演练中恢复到指定时间点",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-034"
  },
  {
   "id": "DB-035",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "备份加密，密钥与备份分开保管；至少一份放在另一个故障域且生产账号不能删改；备份失败、WAL 归档落后要告警",
   "level": "hard",
   "check": "semi",
   "how": "审计备份存储的权限和位置；告警规则覆盖备份任务失败、归档延迟、空间不足",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-035"
  },
  {
   "id": "DB-036",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "定期在隔离环境里做真实恢复演练，校验数据完整、应用可用，并记录实际恢复时间和数据损失",
   "level": "hard",
   "check": "manual",
   "how": "检查演练记录：备份版本、选定的恢复时间点、开始和结束时间、校验 SQL 及结果、实际数据损失、负责人",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-036"
  },
  {
   "id": "DB-037",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "跟上数据库当前的小版本，主版本在社区支持期内；升级数据库或备份工具前读发布说明，执行其中要求的额外步骤",
   "level": "hard",
   "check": "semi",
   "how": "脚本比对运行版本与官方支持列表；升级清单里有「读发布说明」一项并记录结论",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-037"
  },
  {
   "id": "DB-038",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "应用通过一层适配只使用 S3 的常用子集，并在选定的存储产品上跑兼容测试；数据库元数据和对象之间用状态字段加定时对账",
   "level": "soft",
   "check": "semi",
   "how": "对选定后端跑上传、分片上传、删除、列举、预签名的契约测试；对账任务报告孤儿对象和缺失对象",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-038"
  },
  {
   "id": "DB-039",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "对象 key 由服务端生成，不可猜测，带租户前缀；不用用户原始文件名或个人信息做 key",
   "level": "hard",
   "check": "semi",
   "how": "上传集成测试检查生成的 key；评审代码中 key 的拼接方式",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-039"
  },
  {
   "id": "DB-040",
   "topic": "17",
   "topicTitle": "数据库与存储",
   "title": "向量存储先用 pgvector；按自己的数据和过滤条件压测召回率、延迟和维护成本，不达标再换专用向量库",
   "level": "soft",
   "check": "manual",
   "how": "压测记录：召回率（对比精确检索）、p95 延迟、带过滤的返回条数、建索引时间、备份恢复时间",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/database/#db-040"
  },
  {
   "id": "MID-001",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "引入新的中间件前写一份决策记录，写清需求、为什么现有数据库或服务不够、考虑过的替代、退出路径和许可证",
   "level": "soft",
   "check": "manual",
   "how": "评审新增中间件的 PR 时，检查是否附带决策记录（ADR）且五项都有内容",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-001"
  },
  {
   "id": "MID-002",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "给缓存、队列、搜索、调度新增可靠性要求前，先写清容量、积压上限、可丢多少数据、多久恢复和谁负责运维",
   "level": "soft",
   "check": "manual",
   "how": "决策记录或运维手册里有这几项；评审时核对",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-002"
  },
  {
   "id": "MID-003",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "每个中间件记录实际使用的版本、镜像摘要和发行包许可证；生产环境不用 latest 这类浮动标签",
   "level": "hard",
   "check": "auto",
   "how": "CI 扫描部署清单，发现浮动标签或缺少镜像摘要时失败；用 SBOM 工具生成组件清单",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-003"
  },
  {
   "id": "MID-004",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "许可证按实际下载和分发的发行包与版本核对，不只看源码仓库的 LICENSE；每次升级重新核对",
   "level": "hard",
   "check": "semi",
   "how": "升级时对照官方许可证页面和发行包内的许可文件，记录核对结果；许可扫描工具找候选",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-004"
  },
  {
   "id": "MID-005",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "断网交付的项目，交付前实测中间件在断网下的冷启动、重启、故障恢复和升级，所需镜像、依赖、分词词典都预先备好",
   "level": "hard",
   "check": "semi",
   "how": "在断网的验收环境里跑一遍启动、重启、恢复和升级，保留记录",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-005"
  },
  {
   "id": "MID-006",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "新增专用中间件时，写清它挂了业务怎么降级；不能降级的，写明事故处理办法",
   "level": "soft",
   "check": "manual",
   "how": "运维手册里有降级或事故处理一节；故障演练时验证",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-006"
  },
  {
   "id": "MID-007",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "缓存不得作为订单、资金、审批、权限、审计记录的唯一存储；缓存全部丢失后系统必须能从权威数据恢复",
   "level": "hard",
   "check": "semi",
   "how": "测试环境清空缓存后跑核心流程的集成测试，结果必须正确",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-007"
  },
  {
   "id": "MID-008",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "每类缓存键写清负责人、过期时间、键的版本、租户范围、失效方式和缓存不可用时的行为；不设「永不过期」的默认值",
   "level": "soft",
   "check": "semi",
   "how": "缓存键定义集中在一处（代码常量或配置文件），评审时逐类核对；静态检查找没有过期时间的写入",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-008"
  },
  {
   "id": "MID-009",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "多租户系统的缓存键必须包含租户标识，并有跨租户读取的负例测试",
   "level": "hard",
   "check": "auto",
   "how": "集成测试：租户 A 写入后，用租户 B 的身份读同一业务键，必须读不到",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-009"
  },
  {
   "id": "MID-010",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "更新权威数据后必须有明确的缓存一致性策略（更新后删缓存、带版本写入或绕过缓存），并测试写后读和并发竞态",
   "level": "hard",
   "check": "semi",
   "how": "集成测试覆盖「写后立即读」和「并发写与回填缓存交错」两种场景",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-010"
  },
  {
   "id": "MID-011",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "热点缓存处理穿透、击穿和雪崩：负缓存加短过期时间、同键重建合并、过期时间加随机抖动",
   "level": "soft",
   "check": "semi",
   "how": "压测时模拟大量不存在的键、热点键过期、批量同时过期，观察数据库负载",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-011"
  },
  {
   "id": "MID-012",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "缓存登录态或授权结果时必须能及时撤销；负缓存和敏感数据缓存都要有过期时间",
   "level": "hard",
   "check": "semi",
   "how": "测试：撤销用户权限或注销后，在约定时间内缓存不再放行",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-012"
  },
  {
   "id": "MID-013",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "写数据库和发消息要同时成功时，用事务 outbox 或等价机制，不在提交事务后直接发消息",
   "level": "hard",
   "check": "semi",
   "how": "代码评审找「提交后直接 publish」的写法；故障注入测试在提交后、发送前杀掉进程，事件不能丢",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-013"
  },
  {
   "id": "MID-014",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "持久化的消息必须带唯一事件编号、事件类型和版本、发生时间、租户和追踪编号；大载荷只传引用",
   "level": "hard",
   "check": "auto",
   "how": "契约测试用 JSON Schema 校验消息样例必须包含这些字段",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-014"
  },
  {
   "id": "MID-015",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "消费者必须按业务键幂等：同一条消息处理多次，结果和处理一次相同",
   "level": "hard",
   "check": "auto",
   "how": "集成测试把同一条消息重复投递、并发投递，检查业务结果不变",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-015"
  },
  {
   "id": "MID-016",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "不得以中间件的「恰好一次」功能代替业务幂等；对外支付、HTTP 调用、数据库更新的只发生一次由业务唯一性保证",
   "level": "hard",
   "check": "manual",
   "how": "评审有外部副作用的消费者，确认有业务幂等键或下游的幂等键",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-016"
  },
  {
   "id": "MID-017",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "消息和任务的重试必须有次数上限、指数退避加随机抖动、死信或隔离队列，以及人工补偿和重放入口",
   "level": "hard",
   "check": "semi",
   "how": "检查队列配置的最大投递次数和死信设置；故障注入测试一条永远失败的消息，确认它最终进入死信而不是无限循环",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-017"
  },
  {
   "id": "MID-018",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "需要按顺序处理时，写明排序键和顺序范围（同一个业务对象内有序），并测试重平衡和重试后的顺序；不承诺全局有序",
   "level": "soft",
   "check": "semi",
   "how": "集成测试在消费者扩缩容、消息重试时检查同一对象的处理顺序",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-018"
  },
  {
   "id": "MID-019",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "消费者上线前必须通过重复投递、乱序投递和处理中崩溃恢复三类测试",
   "level": "hard",
   "check": "auto",
   "how": "CI 用真实中间件容器跑集成测试：同一消息投两次、打乱顺序投递、处理中途杀掉消费者再重启，检查最终业务状态正确且无丢失",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-019"
  },
  {
   "id": "MID-020",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "不可丢的任务不得用 Core NATS 或其他不持久的发布订阅承载",
   "level": "hard",
   "check": "manual",
   "how": "评审消息通道配置，确认不可丢任务走的是持久化通道（JetStream、RabbitMQ 持久队列、数据库队列）",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-020"
  },
  {
   "id": "MID-021",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "基于 PostgreSQL 的任务队列用现成的库（按语言见选型），不自己写领取、租约、重试和死信逻辑",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查是否有自建的任务表加轮询代码；有的话要求说明现成库为何不适用",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-021"
  },
  {
   "id": "MID-022",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "确有理由自己实现数据库任务领取时，必须有租约到期时间、持有者令牌、续约与过期回收、最大尝试次数和死信状态",
   "level": "hard",
   "check": "auto",
   "how": "集成测试：领取后杀掉工作进程，租约过期后任务被重新领取；超过最大次数后进入死信；用旧令牌提交结果被拒绝",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-022"
  },
  {
   "id": "MID-023",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "领取任务和持有数据库锁的事务里不做网络调用、大模型调用或其他耗时操作",
   "level": "hard",
   "check": "semi",
   "how": "代码评审检查事务块内是否有 HTTP、RPC、大模型调用；监控长事务和锁等待",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-023"
  },
  {
   "id": "MID-024",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "每个定时任务声明唯一编号、时区、错过触发后的处理方式和是否允许重叠执行，并测试夏令时切换",
   "level": "hard",
   "check": "semi",
   "how": "调度配置用 schema 校验这四项必填；对使用有夏令时时区的任务写切换日的测试",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-024"
  },
  {
   "id": "MID-025",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "只有跨天等待、等人审批或外部回调、需要补偿的长流程才引入持久工作流引擎；简单定时和单步异步任务不用",
   "level": "soft",
   "check": "manual",
   "how": "决策记录里写明流程的等待时长、步骤数和补偿需求",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-025"
  },
  {
   "id": "MID-026",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "使用 Temporal 时，Workflow 代码必须确定性；网络、数据库、大模型等外部操作放进 Activity，Activity 自己要幂等",
   "level": "hard",
   "check": "auto",
   "how": "CI 跑 Temporal SDK 的回放测试（用历史事件重放 Workflow），并开启 SDK 的确定性检查",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-026"
  },
  {
   "id": "MID-027",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "不自己写跨重启的「有状态工作流框架」；流程简单就用数据库状态机加任务队列，复杂就用成熟的工作流引擎",
   "level": "soft",
   "check": "manual",
   "how": "评审时识别自建的流程引擎、定时器和补偿框架",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-027"
  },
  {
   "id": "MID-028",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "防重复创建、防重复执行这类业务唯一性，靠数据库唯一约束和条件状态更新，不只靠内存锁或 Redis 锁",
   "level": "hard",
   "check": "semi",
   "how": "检查数据模型里对应字段有唯一约束；并发测试同时提交两次，只成功一次",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-028"
  },
  {
   "id": "MID-029",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "跨系统的强互斥必须使用 fencing token，由下游拒绝旧令牌的写入；不以 Redlock 宣称保证外部一致性",
   "level": "hard",
   "check": "semi",
   "how": "实验：让锁持有者暂停到租约过期，别的实例拿到新锁后，旧持有者恢复写入必须被拒绝",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-029"
  },
  {
   "id": "MID-030",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "多副本部署的全局限流必须用共享的配额源（网关或共享存储的原子计数），不得每个副本各自计数后当成全局配额",
   "level": "hard",
   "check": "auto",
   "how": "压测：多个副本同时承压，统计放行总数不超过配额",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-030"
  },
  {
   "id": "MID-031",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "限流的读取、判断、写入必须是原子操作；超限时返回明确的响应和指标，并写清限流存储不可用时放行还是拒绝",
   "level": "hard",
   "check": "semi",
   "how": "并发测试同一个键的计数准确；评审代码不存在「读取→加一→写回」分开执行；评审配置里的失败策略",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-031"
  },
  {
   "id": "MID-032",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "搜索索引是派生数据：必须能从权威数据全量重建，有重建手册并演练过",
   "level": "hard",
   "check": "semi",
   "how": "定期在测试环境清空索引后按手册全量重建，记录耗时和结果",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-032"
  },
  {
   "id": "MID-033",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "搜索查询必须在检索层强制租户和权限过滤，并有「无权文档搜不到」的负例测试",
   "level": "hard",
   "check": "auto",
   "how": "集成测试用无权用户搜索已知存在的文档，结果里不得出现，包括计数、高亮和联想",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-033"
  },
  {
   "id": "MID-034",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "中文搜索选型和分词器升级前，用本业务的真实查询集评测召回和排序，覆盖短词、专有名词、中英数混合、同义词和权限负例",
   "level": "soft",
   "check": "semi",
   "how": "维护一份带标注答案的查询集，评测时统计前 10 条召回率（Recall@10）、排序质量（NDCG@10）和查询耗时，结果附在决策记录里",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-034"
  },
  {
   "id": "MID-035",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "中间件升级和迁移前做快照，写清回滚或前滚办法；迁移时新旧并行、对账通过后再下线旧系统",
   "level": "soft",
   "check": "manual",
   "how": "升级计划里有快照、回滚步骤和对账口径；在预发环境演练",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-035"
  },
  {
   "id": "MID-036",
   "topic": "18",
   "topicTitle": "中间件选型",
   "title": "业务代码按 S3 兼容接口访问对象存储，不依赖某个实现的私有接口；自建对象存储选仍在维护的项目",
   "level": "soft",
   "check": "semi",
   "how": "依赖检查找特定实现的专有 SDK 和管理接口调用；选型评审确认项目仍在发布安全修复",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/middleware/#mid-036"
  },
  {
   "id": "IAM-001",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "不自己实现密码校验、OAuth/OIDC 令牌签发、SAML 断言解析和 WebAuthn 验证；用成熟的身份提供方或库，例外写 ADR 并经安全评审",
   "level": "hard",
   "check": "manual",
   "how": "评审时检查是否有自写的令牌签发、XML 签名校验、WebAuthn 签名校验代码；依赖清单里有对应的成熟实现，或有经过安全评审的 ADR",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-001"
  },
  {
   "id": "IAM-002",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "交互式登录用 OIDC 授权码流程加 PKCE；公有客户端必须用 PKCE；不用隐式授权，不用密码授权",
   "level": "hard",
   "check": "auto",
   "how": "扫描 IdP 的客户端配置，确认未启用 implicit 和 password 授权类型；集成测试抓取授权请求，确认带 `code_challenge`",
   "applies": [
    "kind:service",
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-002"
  },
  {
   "id": "IAM-003",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "重定向地址在授权服务器上精确匹配登记值，不用通配符或前缀匹配；只有本机回环地址允许端口可变",
   "level": "hard",
   "check": "auto",
   "how": "协议负例测试：用登记地址加路径、换子域、换端口（非回环）发起授权，都必须被拒绝",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-003"
  },
  {
   "id": "IAM-004",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "OIDC 登录必须校验 ID Token 的签名、iss、aud、exp 和 nonce，并校验授权响应的 state；校验失败一律拒绝",
   "level": "hard",
   "check": "auto",
   "how": "负例测试：篡改签名、换 issuer、换 audience、过期、重放 nonce、错 state，都必须登录失败",
   "applies": [
    "kind:service",
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-004"
  },
  {
   "id": "IAM-005",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "SAML 只用于对接已有的企业单点登录；用成熟库校验签名、受众、Recipient/Destination、InResponseTo 和有效期",
   "level": "soft",
   "check": "semi",
   "how": "负例测试：未签名断言、换受众、过期、重放的断言都被拒绝；评审依赖里有成熟的 SAML 库",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-005"
  },
  {
   "id": "IAM-006",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "命令行工具登录用系统浏览器加本机回环回调和 PKCE；没有浏览器时用设备码流程；不在命令行里收集用户密码",
   "level": "hard",
   "check": "semi",
   "how": "评审命令行登录实现，检查没有读取用户密码的输入；集成测试走浏览器或设备码流程",
   "applies": [
    "kind:cli"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-006"
  },
  {
   "id": "IAM-007",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "外部身份用 `(issuer, sub)` 唯一关联内部不可变的用户 ID；邮箱、姓名、手机号不当主键，也不作为自动合并账号的依据",
   "level": "hard",
   "check": "auto",
   "how": "数据库对 `(issuer, subject)` 建唯一约束；检查用户表主键不是邮箱或手机号；测试同邮箱、不同 issuer 的两个账号不会自动合并",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-007"
  },
  {
   "id": "IAM-008",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "员工离职、调岗、停用时，通过 SCIM 或目录同步及时停用账号和收回成员关系，停用后已有会话和令牌失效",
   "level": "soft",
   "check": "semi",
   "how": "演练：在 IdP 停用测试账号后，检查应用内会话、刷新令牌、API Key 是否在约定时间内失效",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-008"
  },
  {
   "id": "IAM-009",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "需要自己管理密码时，单独使用的密码至少 15 位、作为多因素之一时至少 8 位，最长允许至少 64 位；不强制字符组合，不定期强制改密，新密码要比对泄露和常用密码黑名单",
   "level": "hard",
   "check": "auto",
   "how": "表单与接口测试：14 位密码被拒、64 位密码可用、无组合要求；检查没有定期过期策略；用已知泄露密码注册被拒",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-009"
  },
  {
   "id": "IAM-010",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "密码用 Argon2id 哈希存储，参数不低于 19 MiB 内存、2 次迭代、并行度 1；不可用时用 scrypt；要求 FIPS 时用 PBKDF2-HMAC-SHA256；bcrypt 只用于存量系统",
   "level": "hard",
   "check": "auto",
   "how": "代码扫描查找 MD5、SHA-1、SHA-256 直接哈希密码的写法；单元测试读出哈希串里的算法与参数并断言不低于下限",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-010"
  },
  {
   "id": "IAM-011",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "登录、找回密码、MFA 验证接口必须限制连续失败次数；找回密码不泄露账号是否存在，重置链接一次性且短期有效",
   "level": "hard",
   "check": "auto",
   "how": "测试连续错误尝试后被限速或要求额外验证；存在与不存在的账号在找回密码时返回相同提示；重置链接用过一次或过期后失效",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-011"
  },
  {
   "id": "IAM-012",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "多因素认证优先支持通行密钥（passkey/WebAuthn），用 TOTP 或硬件密钥兜底；短信验证码不作为唯一的强因素；提供有身份核验的恢复流程",
   "level": "soft",
   "check": "manual",
   "how": "评审 IdP 的认证器配置和恢复流程",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-012"
  },
  {
   "id": "IAM-013",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "修改密码或邮箱、增删认证器、授予权限、创建 API Key 和委托前，要求近期认证或加强认证（step-up）",
   "level": "hard",
   "check": "auto",
   "how": "端到端测试：会话认证时间超过设定窗口后执行这些操作，必须先重新认证",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-013"
  },
  {
   "id": "IAM-014",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "会话 Cookie 必须设 Secure、HttpOnly 和 SameSite（Lax 或 Strict）；优先用 `__Host-` 前缀，不设 Domain，Path 为 /",
   "level": "hard",
   "check": "auto",
   "how": "端到端测试或响应头检查，断言登录后设置的会话 Cookie 带这些属性",
   "applies": [
    "platform:web",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-014"
  },
  {
   "id": "IAM-015",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "有后端的 Web 应用用服务端会话，或由为前端服务的后端（BFF）持有令牌；浏览器只拿会话 Cookie",
   "level": "soft",
   "check": "manual",
   "how": "评审登录架构，检查浏览器端是否持有访问令牌或刷新令牌",
   "applies": [
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-015"
  },
  {
   "id": "IAM-016",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "登录成功和权限提升后必须更换会话 ID，注销时在服务端销毁会话",
   "level": "hard",
   "check": "auto",
   "how": "测试登录前后会话 ID 不同；注销后用旧会话 ID 访问返回未认证",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-016"
  },
  {
   "id": "IAM-017",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "用 Cookie 鉴权的服务，所有改变状态的请求必须有 CSRF 防护；GET 请求不改状态",
   "level": "hard",
   "check": "auto",
   "how": "测试从跨站来源、不带 CSRF 令牌或 Origin 不符的写请求都被拒绝",
   "applies": [
    "platform:web",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-017"
  },
  {
   "id": "IAM-018",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "会话必须同时有空闲超时和绝对超时，并由服务端判定",
   "level": "hard",
   "check": "auto",
   "how": "测试会话在空闲超时和绝对超时后都需要重新认证；超时由服务端判断，不靠客户端计时",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-018"
  },
  {
   "id": "IAM-019",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "注销、强制下线、修改密码、停用账号后，服务端会话和该用户的刷新令牌必须失效",
   "level": "hard",
   "check": "auto",
   "how": "端到端测试：执行这些操作后，用旧会话和旧刷新令牌访问都失败",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-019"
  },
  {
   "id": "IAM-020",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "业务 API 只接受受众（aud）是自己的访问令牌；不把 ID Token 当 API 凭据",
   "level": "hard",
   "check": "auto",
   "how": "负例测试：用发给其他服务的访问令牌、用 ID Token 调用 API，都必须返回 401",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-020"
  },
  {
   "id": "IAM-021",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "校验 JWT 时用固定的算法白名单（拒绝 none），用可信的密钥验签，并校验 iss、aud、exp、nbf 和所需的 scope；任何一项失败都拒绝",
   "level": "hard",
   "check": "auto",
   "how": "单元测试和负例测试：alg=none、换算法、篡改签名、错 issuer、错 audience、过期、缺 scope 都被拒绝",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-021"
  },
  {
   "id": "IAM-022",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "访问令牌设短有效期；公有客户端的刷新令牌必须每次使用后轮换或绑定发送方，发现旧刷新令牌被重用时撤销整条令牌链",
   "level": "hard",
   "check": "auto",
   "how": "测试刷新后旧刷新令牌失效；用已用过的刷新令牌再刷新时，新旧令牌都被撤销",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-022"
  },
  {
   "id": "IAM-023",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "令牌、会话 ID、API Key 不放在 URL 里，不写进日志、链路追踪、错误信息和大模型提示词",
   "level": "hard",
   "check": "semi",
   "how": "代码扫描查找把令牌放进查询参数的写法；对日志样本跑密钥扫描",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-023"
  },
  {
   "id": "IAM-024",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "撤销权限后，敏感操作必须实时重查权限，不能只看旧 JWT 里的角色或旧的授权缓存；撤权生效的最长时限要写明",
   "level": "hard",
   "check": "auto",
   "how": "时序测试：移除成员或撤销分享后立即执行敏感写操作，必须被拒绝；只读操作在写明的时限内生效",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-024"
  },
  {
   "id": "IAM-025",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "API Key 和个人访问令牌用密码学安全随机数生成，只在创建时显示一次，只存哈希；每个都带所有者、作用域、过期时间、状态和最后使用时间，可撤销、可轮换",
   "level": "hard",
   "check": "auto",
   "how": "检查凭据表里没有可还原的明文列；测试撤销后立即失效、过期后失效；创建后的查询接口不再返回完整值",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-025"
  },
  {
   "id": "IAM-026",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "授权默认拒绝：没有显式策略的接口和操作一律拒绝；授权引擎超时、出错、策略缺失、租户上下文缺失时也拒绝",
   "level": "hard",
   "check": "auto",
   "how": "测试遍历所有路由，确认每个受保护路由都有对应策略；故障注入让授权服务超时，受保护操作必须失败",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-026"
  },
  {
   "id": "IAM-027",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "授权在服务端执行；授权逻辑集中在一个模块或授权服务里，各接口调用它，不在每个控制器里各写一套条件",
   "level": "soft",
   "check": "semi",
   "how": "代码检索散落在控制器里的角色判断；评审时检查新接口是否调用统一的授权入口",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-027"
  },
  {
   "id": "IAM-028",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "读、改、删、列表、搜索、下载、导出、批量和异步任务，每一次都做对象级授权；持有令牌、作用域匹配或同属一个租户都不等于能访问该对象",
   "level": "hard",
   "check": "auto",
   "how": "越权矩阵测试：用户 A 用自己的身份访问用户 B 的私有对象，每个接口、每种动作都必须被拒绝",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-028"
  },
  {
   "id": "IAM-029",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "列表、搜索、计数、聚合和导出必须先按权限确定可见范围再查询，不先查出全部再在内存里过滤，返回的总数和摘要也只算可见对象",
   "level": "hard",
   "check": "auto",
   "how": "差分测试：两个权限不同的用户查同一列表，返回的条数、总数、摘要只包含各自可见的对象",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-029"
  },
  {
   "id": "IAM-030",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "通用的更新接口不允许修改所有者、租户、角色、审批状态这类受保护字段；接受的字段用白名单定义",
   "level": "hard",
   "check": "auto",
   "how": "属性模糊测试：在更新请求里加入 owner_id、tenant_id、role 等字段，确认被忽略或拒绝且数据没变",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-030"
  },
  {
   "id": "IAM-031",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "角色授予必须绑定具体范围（租户、组织、项目等），不存在没有范围的全局业务角色；层级继承规则要写明能否继承、有无显式拒绝、冲突如何处理",
   "level": "hard",
   "check": "semi",
   "how": "数据模型中角色绑定表的范围字段非空；策略单元测试覆盖在 org-1 是管理员、切到 org-2 不保留管理权的情况",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-031"
  },
  {
   "id": "IAM-032",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "授权模型从「带范围的 RBAC + 资源归属检查」起步；出现实例级分享、嵌套团队、跨组织协作或大量「列出我能看的资源」时再引入 ReBAC 引擎；条件规则多时再引入策略引擎",
   "level": "soft",
   "check": "manual",
   "how": "引入授权引擎前写 ADR，说明现有模型解决不了的具体场景",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-032"
  },
  {
   "id": "IAM-033",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "授权策略和角色定义作为代码与业务代码一起版本管理，有正例和反例测试并在 CI 中运行",
   "level": "hard",
   "check": "auto",
   "how": "CI 运行策略测试（如 `opa test`、`cerbosctl` 的策略测试、OpenFGA 的断言测试或项目自己的授权单测），策略变更不带测试时不能合并",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-033"
  },
  {
   "id": "IAM-034",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "每个新的受保护资源或操作，至少有四类负例测试：同租户横向越权、跨租户访问、撤权后访问、伪造主体身份",
   "level": "hard",
   "check": "semi",
   "how": "评审时检查新增资源的测试是否覆盖这四类；可以配合 Schemathesis、OWASP ZAP 做接口层补充",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-034"
  },
  {
   "id": "IAM-035",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "租户标识从服务端已认证的上下文和成员关系中取得，不信任请求参数或前端切换的值；所有读写都带租户约束；跨租户访问只能通过显式的分享或成员关系",
   "level": "hard",
   "check": "auto",
   "how": "跨租户矩阵测试：用租户 A 的身份、带租户 B 的 ID 访问，接口、后台任务、导出全部失败",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-035"
  },
  {
   "id": "IAM-036",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "服务间调用用独立的工作负载身份（OAuth 客户端凭据、双向 TLS 或 SPIFFE），按服务和环境分开；不用个人账号或个人长期令牌充当服务账号",
   "level": "hard",
   "check": "semi",
   "how": "凭据扫描查找配置中的个人令牌；评审服务账号清单，每个服务、每个环境一个身份",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-036"
  },
  {
   "id": "IAM-037",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "Agent 和自动化程序有自己可认证的身份；代表用户操作时，先校验委托记录（范围、目标、有效期、撤销状态）和委托人当前的权限，有效权限取委托人权限、Agent 能力和委托范围的交集；不信任请求里自报的用户 ID",
   "level": "hard",
   "check": "auto",
   "how": "负例测试：请求体里伪造 user_id 或 actor_id 不改变认证身份；委托撤销、过期、越出范围后的调用都被拒绝；委托人被移出项目后 Agent 也失去访问",
   "applies": [
    "kind:service",
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-037"
  },
  {
   "id": "IAM-038",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "委托设过期时间并可随时撤销；默认不允许转委托，子 Agent 不自动继承父 Agent 的全部授权",
   "level": "soft",
   "check": "auto",
   "how": "测试委托过期、撤销后失效；转委托请求在未显式允许时被拒绝",
   "applies": [
    "kind:service",
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-038"
  },
  {
   "id": "IAM-039",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "MCP 认证通过只说明令牌能访问这个服务，工具调用仍要按 IAM-028 做对象级授权；不把收到的令牌原样转发给下游",
   "level": "hard",
   "check": "auto",
   "how": "测试持有有效令牌但对目标对象无权时，工具调用被拒绝；抓包确认调用下游服务时用的是单独取得的令牌",
   "applies": [
    "kind:service",
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-039"
  },
  {
   "id": "IAM-040",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "登录、令牌签发与撤销、权限授予与撤销、委托、授权拒绝和敏感操作都产生结构化审计事件，实际执行者和被代表者分开记录",
   "level": "hard",
   "check": "auto",
   "how": "端到端测试断言审计事件的字段齐全：事件类型、时间、结果、租户、主体、执行者、客户端、对象、动作、原因、请求 ID",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-040"
  },
  {
   "id": "IAM-041",
   "topic": "19",
   "topicTitle": "身份认证与权限",
   "title": "选身份提供方和授权引擎时，在 ADR 中记录许可证、能否私有化和断网运行、协议互通情况和替换路径",
   "level": "soft",
   "check": "manual",
   "how": "评审选型 ADR 是否包含这四项",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/identity/#iam-041"
  },
  {
   "id": "FILE-001",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "按文件内容签名识别真实类型再选解析器；类型与声明不符或不在允许清单里时拒绝，不凭扩展名或客户端给的 MIME 选解析器",
   "level": "hard",
   "check": "auto",
   "how": "测试集放入扩展名与内容不符的样本（如改名为 .pdf 的 ZIP），断言被拒绝或按真实类型处理",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-001"
  },
  {
   "id": "FILE-002",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "解析前配置并强制执行资源上限：原始大小、解压后总大小、压缩比、条目数、嵌套层数、页数、解码后像素、音视频时长",
   "level": "hard",
   "check": "auto",
   "how": "配置检查确认每项上限都有值；测试集放入压缩炸弹、XML 实体炸弹、超大像素图片、超长音视频，断言在上限处停止并报错",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-002"
  },
  {
   "id": "FILE-003",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "解压压缩包时拒绝绝对路径、含 `..`、符号链接和硬链接的条目，解到本任务独立的临时目录，并校验解压后的路径仍在该目录内",
   "level": "hard",
   "check": "auto",
   "how": "测试集放入路径穿越（Zip Slip）和符号链接样本，断言不会写出临时目录；静态扫描查找未校验路径的解压调用",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-003"
  },
  {
   "id": "FILE-004",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "大文件流式读取或按页处理，不把整个文件一次读进内存",
   "level": "soft",
   "check": "semi",
   "how": "评审时查找对不可信文件的整文件读取（如 `read()` 不带长度）；压测时看峰值内存",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-004"
  },
  {
   "id": "FILE-005",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "不可信文件的解析和转换必须在独立进程或容器里运行，并设墙钟超时、CPU、内存、临时盘和并发上限；超时杀掉整个进程组",
   "level": "hard",
   "check": "auto",
   "how": "部署配置检查资源上限字段；故障注入测试（死循环样本、超大样本）断言进程被杀、子进程不残留",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-005"
  },
  {
   "id": "FILE-006",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "解析进程以非 root 运行、默认禁止网络、根文件系统只读、只挂载本任务的临时目录；需要联网的例外写明原因并经批准",
   "level": "hard",
   "check": "auto",
   "how": "部署策略检查（如 Kubernetes 的 securityContext 与 NetworkPolicy、`docker run --network none --read-only`）；运行时抓取网络连接，断言无外联",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-006"
  },
  {
   "id": "FILE-007",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "解析和渲染过程中不主动获取外部资源（远程图片、样式表、字体、XInclude、链接文件）；确需抓取用户给的 URL 时按 25 的 SSRF 规则处理",
   "level": "hard",
   "check": "auto",
   "how": "测试集放入引用外部地址的 HTML、SVG、DOCX、PDF，在抓包或 DNS 记录里断言无请求",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-007"
  },
  {
   "id": "FILE-008",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "解析 XML 类输入（XML、OOXML、ODF、SVG、XHTML、XMP）时禁用 DTD、外部实体和 XInclude",
   "level": "hard",
   "check": "auto",
   "how": "测试集放入 XXE 和实体炸弹样本；静态扫描（Semgrep 等）查找未加固的 XML 解析器构造",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-008"
  },
  {
   "id": "FILE-009",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "不执行 Office 宏、嵌入脚本、OLE 对象，不执行 PDF 中的 JavaScript 和启动动作；LibreOffice 转换时宏安全级别设为最高",
   "level": "hard",
   "check": "semi",
   "how": "测试集放入带宏、带自动执行动作的样本，审计转换进程有无派生意外子进程或外联",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-009"
  },
  {
   "id": "FILE-010",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "调用外部转换工具时用参数数组传参，不经 shell；用户控制的值不拼进命令字符串，文件名用程序生成的临时名",
   "level": "hard",
   "check": "auto",
   "how": "Semgrep、Bandit、gosec 等规则禁止 `shell=True`、`exec` 字符串拼接",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-010"
  },
  {
   "id": "FILE-011",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "用 ImageMagick 处理不可信图片时启用受限的 policy.xml：禁用外部委托程序，禁止读入 MSL、URL、MVG、PS、PDF、SVG、XPS 等格式",
   "level": "hard",
   "check": "auto",
   "how": "`magick identify -list policy` 输出与要求的策略对比；测试集放入 MVG、PS 伪装的图片",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-011"
  },
  {
   "id": "FILE-012",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "用 Pandoc 处理不可信输入时加 `--sandbox`，不启用不可信的过滤器和 Lua 脚本",
   "level": "hard",
   "check": "auto",
   "how": "检查调用 Pandoc 的命令参数含 `--sandbox`；测试集放入含 include 指令、引用本地文件的样本",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-012"
  },
  {
   "id": "FILE-013",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "不把不可信的 PostScript、EPS 交给 Ghostscript；确需处理时保持 SAFER 模式并在隔离环境里运行",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查调用 Ghostscript（含经 ImageMagick、OCRmyPDF 间接调用）的路径和参数",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-013"
  },
  {
   "id": "FILE-014",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "HTML 的正文抽取和安全净化分两步做；要展示或回传 HTML 时必须经过专用净化库，Markdown 转换不算净化",
   "level": "hard",
   "check": "semi",
   "how": "测试集放入 XSS 样本，断言输出中不含可执行脚本；评审时确认净化步骤存在",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-014"
  },
  {
   "id": "FILE-015",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "邮件按 MIME 解码后，正文 HTML 先净化且不加载外链，附件按同一套类型识别和隔离流程分别处理",
   "level": "soft",
   "check": "semi",
   "how": "测试集放入带远程图片、嵌套附件的邮件样本",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-015"
  },
  {
   "id": "FILE-016",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "原生格式先原生读：Office 用结构库，有文字层的 PDF 直接读文字和坐标；先按页检测有无文字层，OCR 只用于没有文字层或文字层损坏的页或区域",
   "level": "soft",
   "check": "semi",
   "how": "路由逻辑的单元测试覆盖数字 PDF、扫描 PDF、混合 PDF；统计每页是否用了 OCR",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-016"
  },
  {
   "id": "FILE-017",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "选解析器或 OCR 引擎前，用自己的真实样本（中文、扫描件、复杂表格、公式等）对比候选，不只看厂商榜单",
   "level": "soft",
   "check": "manual",
   "how": "选型记录里附样本构成、指标（字符错误率、表格结构、阅读顺序、耗时、峰值内存）和结果",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-017"
  },
  {
   "id": "FILE-018",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "升级解析器、OCR 引擎或模型前，在固定样本集上与当前版本对比质量和资源指标，有退步不上线",
   "level": "soft",
   "check": "semi",
   "how": "CI 或发布流程里跑对比，记录新旧结果",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-018"
  },
  {
   "id": "FILE-019",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "保存原件和结构化中间结果；Markdown、纯文本只作派生视图，不作唯一的存储格式",
   "level": "soft",
   "check": "semi",
   "how": "评审存储结构，确认原件与结构化结果都有保存",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-019"
  },
  {
   "id": "FILE-020",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "需要引用出处的场景，每个输出块必须带可回溯的位置：页码和坐标框（注明坐标系）、工作表和单元格范围、幻灯片和形状、DOM 路径或音视频时间码",
   "level": "hard",
   "check": "auto",
   "how": "往返测试：按块记录的位置回原文取内容，断言与块内容一致",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-020"
  },
  {
   "id": "FILE-021",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "输出记录源文件 SHA-256、大小、识别出的类型，以及解析器和模型的名称、版本、配置摘要；同一输入同一配置重试时结果一致",
   "level": "soft",
   "check": "auto",
   "how": "用 JSON Schema 校验输出字段；同一样本重复解析，比较输出哈希",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-021"
  },
  {
   "id": "FILE-022",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "解析结果必须显式标出状态（未尝试、失败、部分成功、成功）和警告；不得在失败时静默返回空内容",
   "level": "hard",
   "check": "auto",
   "how": "错误注入测试（损坏文件、加密文件、超时），断言状态和警告字段正确",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-022"
  },
  {
   "id": "FILE-023",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "需要保留表格结构的场景，保留合并单元格、行列顺序、表头和原始数值类型，不只存显示出来的字符串",
   "level": "soft",
   "check": "auto",
   "how": "含合并单元格、跨页表格、数字和日期的样本做比对测试",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-023"
  },
  {
   "id": "FILE-024",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "OCR 和语音转写结果记录引擎、语言、位置或时间码；引擎不提供置信度时字段留空，不编造数值；「用了 OCR」和「质量低」分开记",
   "level": "hard",
   "check": "auto",
   "how": "用 schema 校验字段；检查置信度只来自引擎原始输出",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-024"
  },
  {
   "id": "FILE-025",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "由模型（视觉模型、大模型）补出或推测的内容单独标注来源，不混作从原文抽取的事实",
   "level": "hard",
   "check": "semi",
   "how": "输出 schema 区分「抽取」与「生成」；人工抽查生成内容是否被标注",
   "applies": [
    "all",
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-025"
  },
  {
   "id": "FILE-026",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "切块时保留每块的出处位置和父级标题路径；切块策略见 24",
   "level": "soft",
   "check": "auto",
   "how": "每块回原文反查位置",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-026"
  },
  {
   "id": "FILE-027",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "生成 Office 文件用该语言的原生库，不用改扩展名的 CSV 冒充 XLSX；公式、日期、数值类型做往返测试",
   "level": "soft",
   "check": "auto",
   "how": "生成后用同一库或另一种库读回，比对单元格类型和值",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-027"
  },
  {
   "id": "FILE-028",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "LibreOffice 无头转换每个任务使用独立的用户配置目录和临时目录，任务结束后清理，不同任务不共享配置",
   "level": "soft",
   "check": "semi",
   "how": "检查调用参数含 `-env:UserInstallation=file:///...` 且路径按任务生成",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-028"
  },
  {
   "id": "FILE-029",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "生成或转换 PDF 时预置并嵌入 CJK 字体；转换后检查页数、能否抽出文字、关键页截图对比，不只看命令返回码",
   "level": "soft",
   "check": "semi",
   "how": "转换流水线里跑文本抽取和截图对比测试",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-029"
  },
  {
   "id": "FILE-030",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "要求离线部署的项目，模型权重、OCR 语言包、字体、浏览器、编解码器预置进镜像，并在禁网容器里跑通端到端冒烟测试；生产环境不在运行时从公网下载模型",
   "level": "hard",
   "check": "auto",
   "how": "CI 用 `docker run --network none` 跑解析、OCR、转换的冒烟测试；同时检查 DNS 和连接记录",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-030"
  },
  {
   "id": "FILE-031",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "真实文件上传到云端 OCR 或文档识别服务前，必须完成数据分类和审批；要求断网的项目不得使用",
   "level": "hard",
   "check": "manual",
   "how": "评审时核对审批记录，包括数据类型、服务所在地域、保留期限",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-031"
  },
  {
   "id": "FILE-032",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "解析产生的临时文件和中间产物在任务结束或超时后删除；日志只记哈希、大小、类型，不记文件正文",
   "level": "soft",
   "check": "semi",
   "how": "生命周期测试检查临时目录被清空；日志抽查",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-032"
  },
  {
   "id": "FILE-033",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "解析组件的代码、模型权重、字体、编解码器分别核对许可证；AGPL、GPL、带商业门槛或非商用条款的组件，在随产品分发或对外提供服务前按 27 审批",
   "level": "hard",
   "check": "semi",
   "how": "许可扫描工具找出候选，人工核对官方仓库的 LICENSE 和模型许可文件，留审批记录",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-033"
  },
  {
   "id": "FILE-034",
   "topic": "20",
   "topicTitle": "文档解析与内容处理",
   "title": "解析相关的依赖、模型和字体记入台账（版本、许可、哈希），并订阅它们的安全公告和许可变更",
   "level": "soft",
   "check": "auto",
   "how": "SBOM 和许可扫描在 CI 里运行；依赖更新工具提醒新版本",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/documents/#file-034"
  },
  {
   "id": "INT-001",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "业务代码只调用自己的通知或集成适配层，服务商 SDK 和服务商地址只出现在适配层里",
   "level": "soft",
   "check": "auto",
   "how": "用依赖边界 lint（如 dependency-cruiser、import-linter、ArchUnit）禁止适配层以外的模块引用服务商 SDK 包和服务商域名",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-001"
  },
  {
   "id": "INT-002",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "调用第三方时遵守对方的限流：收到 429 或 503 按 `Retry-After` 等待，没有时退避加抖动；按服务商公布的配额做客户端限流，各层重试不叠加",
   "level": "hard",
   "check": "semi",
   "how": "用模拟服务返回 429/503（带和不带 `Retry-After`），配合假时钟检查等待时间和总次数；评审时检查 SDK、网关、任务队列是否各自开着重试",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-002"
  },
  {
   "id": "INT-003",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "有副作用的外部请求（发送、扣款、开通）在发出后超时或断连，把结果标为「未知」，先查状态或对账再决定，不直接当失败重发",
   "level": "hard",
   "check": "semi",
   "how": "故障注入测试：让模拟服务执行成功但不返回响应，检查系统进入「未知」状态并走查询或对账，没有重复发送",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-003"
  },
  {
   "id": "INT-004",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "可能重试的外部写请求带一个持久化的去重键；服务商支持幂等键时必须传",
   "level": "hard",
   "check": "auto",
   "how": "测试用同一个业务操作连续调用两次，检查服务商侧（模拟服务）只产生一次副作用；去重键在本地有唯一约束",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-004"
  },
  {
   "id": "INT-005",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "按错误类别处理第三方返回：参数错误和业务拒绝不重试；认证失败暂停该连接并告警；退订、拉黑、永久退信记入抑制名单",
   "level": "soft",
   "check": "semi",
   "how": "适配层合同测试逐类返回错误，检查是否重试、是否暂停、是否写抑制名单",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-005"
  },
  {
   "id": "INT-006",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "测试和 CI 不得真实发送消息或产生扣费：用 fake 或服务商沙箱；沙箱、预发、生产的凭据和推送环境分开",
   "level": "hard",
   "check": "auto",
   "how": "CI 默认禁止出网（或只放行沙箱域名），统计真实外发次数必须为 0；检查各环境的密钥命名和配置，生产凭据不出现在非生产环境",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-006"
  },
  {
   "id": "INT-007",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "每个适配层有合同测试，覆盖成功、4xx、5xx、429、超时、断连、乱序回执和缺字段",
   "level": "soft",
   "check": "auto",
   "how": "在 CI 里对每个适配层跑同一套合同测试，用模拟服务返回各类情况",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-007"
  },
  {
   "id": "INT-008",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "webhook 地址、机器人地址、预签名链接都当作凭据：加密保存，不进日志、错误信息和群聊",
   "level": "hard",
   "check": "auto",
   "how": "日志脱敏测试；对日志和配置跑密钥扫描，规则包含常见的机器人地址格式",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-008"
  },
  {
   "id": "INT-009",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "声称支持断网部署时，必须跑断网验收；断网模式下不得悄悄回退去调用公网服务",
   "level": "hard",
   "check": "auto",
   "how": "在禁止出网的环境里启动并跑核心流程，同时记录 DNS 和出站连接，必须为 0",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-009"
  },
  {
   "id": "INT-010",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "短信、推送、地图等按量计费的外部服务，按项目和租户设预算上限和异常告警",
   "level": "soft",
   "check": "semi",
   "how": "检查预算配置存在；用模拟流量验证超限时告警并停发",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-010"
  },
  {
   "id": "INT-011",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "收到第三方回调时，先按服务商的算法对原始请求字节验签，通过后才解析和使用其中的字段",
   "level": "hard",
   "check": "auto",
   "how": "用服务商的官方测试向量或官方验签库写测试：正确签名通过；改动请求体、时间戳或签名任意一处都失败；先解析 JSON 再重新序列化验签的写法被测试抓出",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-011"
  },
  {
   "id": "INT-012",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "服务商按 Standard Webhooks 签名时，用官方库验签：检查 `webhook-timestamp` 在容忍窗口内，逐个尝试 `webhook-signature` 里的签名，任一匹配即通过",
   "level": "hard",
   "check": "auto",
   "how": "用官方库的测试：过期和未来过远的时间戳被拒；轮换期间带两个签名时新旧密钥都能通过；篡改 `webhook-id` 失败",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-012"
  },
  {
   "id": "INT-013",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "回调内容既有签名又有加密时，先验签再解密；「能解密」不能当作「已验签」",
   "level": "hard",
   "check": "auto",
   "how": "测试用签名错误但能正常解密的回调，必须被拒绝",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-013"
  },
  {
   "id": "INT-014",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "验签通过后，先把回调写入收件箱表（按服务商和事件 ID 唯一），再返回 2xx；业务处理放到异步任务里",
   "level": "hard",
   "check": "auto",
   "how": "测试同一事件并发投递多次，收件箱只有一条、业务只处理一次；在处理逻辑里注入异常，检查已返回 2xx 的事件之后仍会被处理",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-014"
  },
  {
   "id": "INT-015",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "回调处理按至少一次投递设计：容忍重复、乱序和迟到；状态只能沿规定方向推进，迟到的旧状态不覆盖更新的状态",
   "level": "hard",
   "check": "auto",
   "how": "测试按乱序投递一组回调（如先「退信」后「已受理」），检查最终状态正确；用带旧状态条件的更新语句或乐观锁保证不倒退",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-015"
  },
  {
   "id": "INT-016",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "支付等资金结果以「服务端验签回调 + 主动查单 + 定期对账」为准，不凭客户端成功页发货；金额用最小货币单位的整数",
   "level": "hard",
   "check": "auto",
   "how": "测试只有客户端成功、没有回调时不发货；对账任务用有差异的账单样例跑，差异进入人工处理队列；金额字段类型检查不允许浮点数",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-016"
  },
  {
   "id": "INT-017",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "重要通知的发送意图和业务写入在同一个事务里提交（outbox），不在业务代码里直接调用发送接口",
   "level": "hard",
   "check": "semi",
   "how": "测试业务事务回滚时没有通知发出、提交后通知一定会发出；评审时查找业务事务里直接调用发送接口的写法",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-017"
  },
  {
   "id": "INT-018",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "送达状态分清「服务商已受理」「已送达」「已读」；没有对应回执的保持「未知」或「不支持」，不把接口返回成功当成已送达",
   "level": "soft",
   "check": "auto",
   "how": "状态机测试：只有收到明确回执才能进入「已送达」「已读」；统计报表区分各状态",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-018"
  },
  {
   "id": "INT-019",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "通知带用途（交易、安全、营销、告警）和紧急程度两个字段；营销退订不影响交易和安全通知，强制发送的通知类别要经产品和合规确认",
   "level": "soft",
   "check": "semi",
   "how": "检查通知定义都带用途字段；测试退订营销后交易通知照常发送；强制发送的类别列表有评审记录",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-019"
  },
  {
   "id": "INT-020",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "通知偏好按「类别 × 渠道」保存；静默时段按用户时区计算；退订记录来源和时间，并立即生效",
   "level": "soft",
   "check": "auto",
   "how": "用假时钟测试跨时区和夏令时的静默时段；测试退订后下一条营销通知不再发出",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-020"
  },
  {
   "id": "INT-021",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "频控分层：同一事件先合并，再按用户、号码或设备、租户、服务商分别限频；告警默认逐级升级，不同时多渠道轰炸",
   "level": "soft",
   "check": "auto",
   "how": "用假时钟和并发测试验证各层限频；同一故障的告警按事件编号聚合",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-021"
  },
  {
   "id": "INT-022",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "通知模板的文案放进资源文件，按收件人的语言和时区渲染；模板带版本号，发送记录里记下用了哪个版本",
   "level": "soft",
   "check": "auto",
   "how": "i18n 检查覆盖邮件和通知模板；发送记录字段检查",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-022"
  },
  {
   "id": "INT-023",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "生产发信域必须配置 SPF、DKIM 和 DMARC，且 `From` 域与 SPF 或 DKIM 对齐；有效的反向解析和 TLS 发信",
   "level": "hard",
   "check": "auto",
   "how": "上线前和定期用 DNS 查询检查三条记录；发一封到测试邮箱，检查认证结果头里 SPF、DKIM、DMARC 都是 pass",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-023"
  },
  {
   "id": "INT-024",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "营销和订阅邮件必须有可见的退订入口，并带 RFC 8058 一键退订头（`List-Unsubscribe` 和 `List-Unsubscribe-Post`），两天内处理退订",
   "level": "hard",
   "check": "auto",
   "how": "邮件头测试检查两个头存在且格式正确；集成测试向退订地址发 POST 后，该地址进入退订名单",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-024"
  },
  {
   "id": "INT-025",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "交易、安全、营销邮件用不同的发送身份或子域；硬退信和投诉的地址进入抑制名单，停止非必要发送",
   "level": "soft",
   "check": "auto",
   "how": "回放退信和投诉回执，检查地址进入抑制名单、下一次发送被拦截",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-025"
  },
  {
   "id": "INT-026",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "邮件模板里的变量必须做 HTML 转义；每封 HTML 邮件同时提供纯文本版本",
   "level": "hard",
   "check": "auto",
   "how": "模板快照测试里放入带 HTML 和脚本的变量，检查输出已转义；检查发出的邮件有 text/plain 部分",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-026"
  },
  {
   "id": "INT-027",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "发短信的接口在服务端按号码、账号、设备、IP、租户多维限频，并设每日费用上限和告警；不只靠前端倒计时和服务商的默认限频",
   "level": "hard",
   "check": "auto",
   "how": "用模拟攻击流量（轮换号码、轮换 IP）测试限频和费用熔断生效",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-027"
  },
  {
   "id": "INT-028",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "验证码和完整手机号不写进日志、监控标签、队列错误信息和群聊告警",
   "level": "hard",
   "check": "auto",
   "how": "日志脱敏测试；在测试数据里放已知验证码和号码，扫描日志和告警输出中不出现",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-028"
  },
  {
   "id": "INT-029",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "国内短信只用已审核通过的签名和模板，验证码、通知、营销分别用对应用途的模板；营销短信上线前按 27 做法规检查",
   "level": "hard",
   "check": "manual",
   "how": "上线前核对服务商控制台里的签名和模板审核结果，并和代码里使用的模板编号对照",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-029"
  },
  {
   "id": "INT-030",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "APNs 用基于令牌的认证（.p8 密钥签 JWT），复用 HTTP/2 长连接；JWT 每 20 到 60 分钟刷新一次，不每条消息新建连接或新签令牌",
   "level": "hard",
   "check": "semi",
   "how": "评审推送客户端的连接和令牌缓存逻辑；压测时观察连接数和是否出现 `TooManyProviderTokenUpdates`",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-030"
  },
  {
   "id": "INT-031",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "FCM 用 HTTP v1 接口和服务账号的 OAuth 2.0 访问令牌（或官方 Admin SDK），不得使用已关停的旧版接口和服务器密钥",
   "level": "hard",
   "check": "auto",
   "how": "代码扫描旧接口地址 `fcm.googleapis.com/fcm/send` 和服务器密钥的用法",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-031"
  },
  {
   "id": "INT-032",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "设备令牌按用户、应用、平台、环境分开保存；服务商返回令牌失效时立即停用，用户退出登录或解绑时删除",
   "level": "hard",
   "check": "auto",
   "how": "测试模拟服务返回令牌失效错误后，该令牌不再被使用；测试退出登录后令牌被删除",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-032"
  },
  {
   "id": "INT-033",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "IM 适配层按「纯文本、卡片、交互」三档声明能力；目标不支持交互回调时（如群自定义机器人），不发带操作按钮的卡片",
   "level": "soft",
   "check": "auto",
   "how": "每个 IM 适配层有能力声明，渲染测试检查不支持交互的目标收到的消息里没有操作按钮",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-033"
  },
  {
   "id": "INT-034",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "接收 IM 交互回调时按平台要求验签；按钮动作绑定业务幂等键，并校验点击者有权执行这个动作",
   "level": "hard",
   "check": "auto",
   "how": "测试伪造签名的交互请求被拒；同一按钮点两次只执行一次；无权限的用户点击被拒",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-034"
  },
  {
   "id": "INT-035",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "新建的 Microsoft Teams 集成不得使用已退役的 Office 365 Connectors（旧式 Incoming Webhook），改用 Workflows 或 Teams 机器人",
   "level": "hard",
   "check": "auto",
   "how": "代码和配置扫描旧连接器的地址格式（`webhook.office.com/webhookb2/`）",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-035"
  },
  {
   "id": "INT-036",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "接入用户的第三方账号时用授权码流程加 PKCE 和 `state`，只申请当前功能需要的权限范围",
   "level": "hard",
   "check": "semi",
   "how": "评审授权请求参数（`code_challenge`、`state`、`scope`）；测试回调里 `state` 不匹配时被拒",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-036"
  },
  {
   "id": "INT-037",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "第三方访问令牌和刷新令牌加密存储，只在服务端使用，不下发到前端、不写进日志",
   "level": "hard",
   "check": "auto",
   "how": "数据库字段检查为加密存储；日志脱敏测试；接口响应扫描不含令牌",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-037"
  },
  {
   "id": "INT-038",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "刷新令牌时同一连接只允许一个刷新在执行，保存服务商返回的新刷新令牌；刷新被拒（如 `invalid_grant`）时把连接标为失效并提示用户重新授权，不反复重试",
   "level": "soft",
   "check": "auto",
   "how": "并发测试多个请求同时遇到令牌过期，只发生一次刷新；测试刷新被拒后连接状态变为失效",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-038"
  },
  {
   "id": "INT-039",
   "topic": "21",
   "topicTitle": "通知与第三方集成",
   "title": "用户断开第三方连接或注销账号时，服务商支持撤销接口的调用撤销（RFC 7009），并删除本地保存的令牌",
   "level": "hard",
   "check": "semi",
   "how": "测试断开连接后本地令牌被删除、撤销接口被调用；评审每个连接的撤销实现",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/integrations/#int-039"
  },
  {
   "id": "DATA-001",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "订单、验收、结算、计费等业务事实只从服务端事务或业务事件派生，不用客户端埋点推导",
   "level": "hard",
   "check": "semi",
   "how": "评审数据血缘：账单、结算和核心业务指标的源表必须是服务端事实表；事件注册表里标为权威的事件，来源只能是服务端",
   "applies": [
    "kind:data",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-001"
  },
  {
   "id": "DATA-002",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "把业务事实、成本计量、实验分配与曝光、可选行为遥测分成四类，分别存放并分别定义保留期",
   "level": "soft",
   "check": "manual",
   "how": "评审事件注册表：每个事件标明所属类别；检查各类别有各自的表或分区和保留期",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-002"
  },
  {
   "id": "DATA-003",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "新事件上线前先登记到仓库里的事件注册表，写明名称、版本、负责人、触发时机、属性白名单、隐私级别、保留期和采样方式",
   "level": "soft",
   "check": "auto",
   "how": "CI 用 JSON Schema 校验注册表格式，并扫描代码里上报的事件名，未登记的报错",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-003"
  },
  {
   "id": "DATA-004",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "事件名全库用同一种风格：小写蛇形、「对象_动作过去式」；界面行为加统一前缀，不用 click、success、test、event1 这类没有业务含义的名字",
   "level": "soft",
   "check": "auto",
   "how": "CI 用正则校验注册表里的事件名，并检查禁用词表",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-004"
  },
  {
   "id": "DATA-005",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "每条事件必须带唯一事件 ID、事件名、schema 版本、发生时间和接收时间（UTC）、来源；多租户系统还必须带租户 ID",
   "level": "hard",
   "check": "auto",
   "how": "入口处用 JSON Schema 校验，缺字段的拒收并计数；契约测试覆盖每类事件",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-005"
  },
  {
   "id": "DATA-006",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "事件属性只放白名单里的字段；不放完整 URL 和查询参数、自由文本、文件名、邮箱、手机号、IP、令牌、提示词原文",
   "level": "hard",
   "check": "semi",
   "how": "上报 SDK 统一过滤钩子并有单元测试；CI 对注册表的属性名做敏感词扫描；定期抽样检查入库数据",
   "applies": [
    "kind:data",
    "platform:web",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-006"
  },
  {
   "id": "DATA-007",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "事件结构改名、改类型、改含义时升 schema 版本，新旧版本并行一段时间；只新增可选属性不升版本",
   "level": "soft",
   "check": "auto",
   "how": "CI 对比注册表和 schema 的改动，发现不兼容变化而版本号没变时报错",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-007"
  },
  {
   "id": "DATA-008",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "时间以 UTC 存储，报表声明业务时区；金额、计数、时间等用有类型的列，不全塞进一个 JSON 字段",
   "level": "soft",
   "check": "semi",
   "how": "评审表结构：核心查询用到的字段是否是有类型的列；报表定义是否写明时区",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-008"
  },
  {
   "id": "DATA-009",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "匿名 ID 只在可选遥测开启后生成；登录后的身份关联用显式事件完成；不靠 IP、UA 或设备指纹识别用户；退出登录或切换租户后清掉匿名状态",
   "level": "hard",
   "check": "semi",
   "how": "端到端测试检查遥测关闭时没有生成标识；跨租户测试检查切换后不会串号；评审身份合并逻辑",
   "applies": [
    "kind:data",
    "platform:web",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-009"
  },
  {
   "id": "DATA-010",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "业务事实、成本计量、实验分配与曝光不得采样；行为遥测要采样时，按稳定的键做确定性抽样并记录采样概率",
   "level": "hard",
   "check": "semi",
   "how": "扫描采集配置，权威类别的事件采样率必须为 1；遥测事件的 schema 必须有采样概率字段",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-010"
  },
  {
   "id": "DATA-011",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "分析侧消费事件按事件 ID 幂等；同一批输入重放两次结果不变，乱序和迟到能重算修正",
   "level": "hard",
   "check": "auto",
   "how": "集成测试把同一批事件（含重复、乱序、迟到）重放两次，比对汇总结果一致",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-011"
  },
  {
   "id": "DATA-012",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "可选遥测按用户的同意状态开关：同意前不加载分析脚本、不发请求、不写跟踪用的 cookie 或本地标识；撤回同意后立即停止并清除标识",
   "level": "hard",
   "check": "auto",
   "how": "端到端测试在未同意和撤回两种状态下记录网络请求和存储写入，出现分析域名请求或跟踪标识即失败",
   "applies": [
    "kind:data",
    "platform:web",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-012"
  },
  {
   "id": "DATA-013",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "私有化或断网部署时，可选遥测默认关闭；分析脚本和依赖本地托管，断网环境下零外发",
   "level": "hard",
   "check": "auto",
   "how": "在禁止外部 DNS 和 HTTP 的环境里跑安装、启动、升级和看板使用，记录出站请求，必须为零",
   "applies": [
    "kind:data",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-013"
  },
  {
   "id": "DATA-014",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "关键指标写成仓库里的机器可读定义：ID、版本、负责人、含义、粒度、分子分母、去重与排除规则、时间窗、业务时区、零分母处理、数据源，并带示例数据测试",
   "level": "soft",
   "check": "auto",
   "how": "CI 用 schema 校验指标定义文件，并对每个指标跑示例数据测试",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-014"
  },
  {
   "id": "DATA-015",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "改变指标口径时升指标版本，新旧版本并行对照一段时间，并在图表上标出变更点；不静默改 SQL",
   "level": "soft",
   "check": "auto",
   "how": "CI 发现指标的 SQL 或定义改动而版本号没变时报错",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-015"
  },
  {
   "id": "DATA-016",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "主要指标配护栏指标一起看；不用单一指标考核个人；按人员维度统计要写明用途并限制查看权限",
   "level": "soft",
   "check": "manual",
   "how": "评审指标定义：主要指标是否列了护栏；人员维度的报表是否有用途说明和权限设置",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-016"
  },
  {
   "id": "DATA-017",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "核心看板和指标卡片显示指标版本、统计区间、数据截至时间和数据质量状态；导出时服务端重新鉴权",
   "level": "soft",
   "check": "semi",
   "how": "界面测试检查核心卡片上这些信息存在；评审导出接口的鉴权",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-017"
  },
  {
   "id": "DATA-018",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "默认用 PostgreSQL 加索引、分区和汇总表做分析；引入 OLAP 前用同一份数据和真实查询压测比较，结果写进 ADR",
   "level": "soft",
   "check": "manual",
   "how": "评审 ADR：是否有基线方案和候选方案在同样数据、查询、并发下的延迟、资源和运维成本对比",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-018"
  },
  {
   "id": "DATA-019",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "BI 和分析查询用只读账号，只授权分析用的库或 schema；大扫描不在业务主库上跑",
   "level": "hard",
   "check": "auto",
   "how": "审计数据库角色：BI 账号没有写权限、没有业务 schema 的访问权；分析查询指向读副本或分析库",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-019"
  },
  {
   "id": "DATA-020",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "多租户报表和嵌入看板的租户过滤在服务端或数据库层强制，不信任前端传来的租户参数；嵌入时用服务端签名锁定参数",
   "level": "hard",
   "check": "semi",
   "how": "跨租户测试：篡改前端参数、嵌入令牌和导出请求，不能拿到其他租户的数据",
   "applies": [
    "kind:data",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-020"
  },
  {
   "id": "DATA-021",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "原始数据层只追加不改写；清洗、事实与维度、指标分层加工，每层的转换 SQL 放版本库、走评审、带测试",
   "level": "soft",
   "check": "semi",
   "how": "评审转换代码是否在仓库里；检查原始层没有 UPDATE、DELETE（按保留期清理除外）",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-021"
  },
  {
   "id": "DATA-022",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "DuckDB 用作嵌入式分析和批处理，不当作多人并发写入的中心服务；所需扩展预先安装或打进镜像",
   "level": "soft",
   "check": "semi",
   "how": "评审部署架构；断网环境里检查扩展不会在运行时下载",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-022"
  },
  {
   "id": "DATA-023",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "Python 数据处理用列式、向量化的接口（Polars、DuckDB SQL 或 pandas 的向量化操作），不逐行循环；依赖按 12 锁定",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查对 DataFrame 的逐行迭代（如 iterrows、apply 逐行调用）",
   "applies": [
    "kind:data",
    "lang:python"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-023"
  },
  {
   "id": "DATA-024",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "提交到仓库的 notebook 必须清空输出，不含密钥和真实客户数据",
   "level": "hard",
   "check": "auto",
   "how": "提交前钩子跑 nbstripout（或同类工具）并在 CI 复查；对 notebook 跑密钥扫描",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-024"
  },
  {
   "id": "DATA-025",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "notebook 只做探索；进入定时任务、报表或指标的逻辑移到模块或 SQL 里，并有测试",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查定时任务和报表是否直接调用 notebook",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-025"
  },
  {
   "id": "DATA-026",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "关键表必须有自动化的数据质量检查：主键唯一、必填非空、引用关系、取值范围和新鲜度；失败时阻断下游发布或标记看板",
   "level": "hard",
   "check": "auto",
   "how": "用 dbt 数据测试、Great Expectations 或 SQL 断言在每次加工后运行，失败返回非零并告警",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-026"
  },
  {
   "id": "DATA-027",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "回填和重算要限定范围、写入新版本或临时表、校验行数唯一键和金额合计后再原子切换，留下记录并能回退",
   "level": "soft",
   "check": "semi",
   "how": "回填脚本用统一模板，执行前后的校验结果和审批记录附在工单或 PR 里",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-027"
  },
  {
   "id": "DATA-028",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "正式 A/B 实验上线前写好实验计划：假设、主要指标与护栏、随机化单位、分配比例、最小可检测效应、样本量与时长、停止规则",
   "level": "hard",
   "check": "manual",
   "how": "实验平台或仓库里有计划文件，评审通过后才能开启流量；分析报告引用计划版本",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-028"
  },
  {
   "id": "DATA-029",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "样本量按功效分析事先算好；不反复查看 p 值、显著了就停；需要提前停止的，事先声明并使用序贯检验或贝叶斯方法",
   "level": "hard",
   "check": "manual",
   "how": "评审实验报告：实际停止时间与计划一致，或者计划里声明了序贯方法",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-029"
  },
  {
   "id": "DATA-030",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "分配和曝光分开记录，按随机化单位分析；SRM（各组样本比例与设计不符）检查不通过时不下结论、不自动放量",
   "level": "hard",
   "check": "auto",
   "how": "实验分析流水线对各组样本数跑卡方检验，不通过时报告标红并阻止发布胜者",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-030"
  },
  {
   "id": "DATA-031",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "用户或客户太少、达不到所需样本量时不做 A/B，改用灰度、前后对照或盲评，并在结论里写明证据强度",
   "level": "soft",
   "check": "manual",
   "how": "评审实验计划：算出的样本量与实际可用流量对比",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-031"
  },
  {
   "id": "DATA-032",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "功能开关不能代替鉴权；权限相关的判断由服务端完成",
   "level": "hard",
   "check": "semi",
   "how": "安全测试：在客户端改掉开关值，受保护的功能和数据仍不可访问",
   "applies": [
    "kind:service",
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-032"
  },
  {
   "id": "DATA-033",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "动态开关登记负责人、用途、默认值、环境和过期时间，到期清理；一次请求或一次执行内用同一份开关快照，并记录开关和变体",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查开关注册表字段完整，并对过期开关报警",
   "applies": [
    "kind:service",
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-033"
  },
  {
   "id": "DATA-034",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "按要回答的问题选图表类型：随时间变化用折线，类别比较用条形，构成用堆叠条形，分布用直方图或箱线图，关系用散点；不用 3D 图",
   "level": "soft",
   "check": "manual",
   "how": "评审看板时逐张检查图表类型和标题是否对应一个明确的问题",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-034"
  },
  {
   "id": "DATA-035",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "条形图和面积图的数值轴从零开始；坐标轴标出单位，时间轴按真实间隔；标题或注释写明口径和数据截至时间",
   "level": "soft",
   "check": "semi",
   "how": "图表组件封装默认配置（条形图 Y 轴从零、必填单位）；评审时抽查",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-035"
  },
  {
   "id": "DATA-036",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "图表配色只用设计系统的数据色板；系列多时合并为「其他」或拆图；不只靠颜色区分系列，并提供数据表或文字结论",
   "level": "soft",
   "check": "semi",
   "how": "检查图表配置的颜色是否引用色板令牌；评审时看是否有图例以外的区分方式和数据表",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-036"
  },
  {
   "id": "DATA-037",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "成本金额用精确小数或约定单位的整数，同时记录币种；多币种保留原币金额和汇率快照",
   "level": "hard",
   "check": "auto",
   "how": "检查表结构里金额列的类型；单元测试覆盖舍入和币种换算",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-037"
  },
  {
   "id": "DATA-038",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "成本分录写入后不修改；更正写冲销分录；价目表带生效区间和版本，调价不改写历史",
   "level": "hard",
   "check": "semi",
   "how": "数据库权限禁止对分录表 UPDATE 和 DELETE；账本测试覆盖冲销和调价场景",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-038"
  },
  {
   "id": "DATA-039",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "用量、估算成本、实际账单分开记；每笔成本带功能、租户、项目等归属维度和分摊规则版本，归不了属的单列为未分摊，并定期与供应商账单对账",
   "level": "soft",
   "check": "semi",
   "how": "账本测试检查分摊前后合计相等；对账报表显示与供应商账单的差额和未分摊占比",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-039"
  },
  {
   "id": "DATA-040",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "引入分析组件前写清许可证和使用方式（独立运行还是集成进产品分发）；AGPL 或专有许可的组件要嵌进交付物时先过许可审查",
   "level": "hard",
   "check": "semi",
   "how": "依赖清单记录每个组件的许可证和使用方式；许可扫描工具和评审按 27 的口径判定",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-040"
  },
  {
   "id": "DATA-041",
   "topic": "22",
   "topicTitle": "数据分析与统计",
   "title": "BI、实验统计、产品分析优先用成熟的现成工具，不自造 SQL 编辑器、报表权限系统或显著性检验引擎",
   "level": "soft",
   "check": "manual",
   "how": "设计评审时检查是否有对应的现成方案及不用它的理由",
   "applies": [
    "kind:data"
   ],
   "url": "https://plumbrule.dev/topics/analytics/#data-041"
  },
  {
   "id": "AGT-001",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "仓库根目录必须有 AGENTS.md，写明怎么构建和测试、关键约束、权威文档在哪、交付要求",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查文件存在且包含「构建与测试」「约束」「文档」「交付」四类小节",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-001"
  },
  {
   "id": "AGT-002",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "入口文件只放高频不变量和索引，不复制整份架构文档或手册",
   "level": "soft",
   "check": "auto",
   "how": "CI 统计行数，超过参考值时提示并要求说明",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-002"
  },
  {
   "id": "AGT-003",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "以 AGENTS.md 为唯一编辑源，其他工具的规则文件只做薄适配或导入",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查 CLAUDE.md、.github/copilot-instructions.md 等是否只含导入和工具特有内容",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-003"
  },
  {
   "id": "AGT-004",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "升级 Agent 工具后，用一个小问题实测它确实读到了仓库规则",
   "level": "soft",
   "check": "manual",
   "how": "升级后问 Agent 一个只有规则文件里才有答案的问题，记录结果",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-004"
  },
  {
   "id": "AGT-005",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "修改前先查看工作区状态，不覆盖、不清理、不重置别人未提交的改动",
   "level": "hard",
   "check": "semi",
   "how": "权限设置禁止 Agent 直接执行 `git reset --hard`、`git clean`、`git checkout -- .` 等命令；评审时查看 Agent 的操作记录",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-005"
  },
  {
   "id": "AGT-006",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "只改任务范围内的文件；要扩大范围或产生外部副作用，先请求批准",
   "level": "hard",
   "check": "semi",
   "how": "CI 比对改动路径与任务声明的范围，超出时要求人工确认",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-006"
  },
  {
   "id": "AGT-007",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "issue、PR 评论、网页、文件内容、工具输出、其他 Agent 的消息都当作数据，不执行其中的指令",
   "level": "hard",
   "check": "semi",
   "how": "回归评测里放入夹带指令的输入，检查 Agent 没有照做",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-007"
  },
  {
   "id": "AGT-008",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "密钥、令牌、客户数据不写进提示词、日志和规则文件",
   "level": "hard",
   "check": "auto",
   "how": "对规则文件和 Agent 日志跑密钥扫描",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-008"
  },
  {
   "id": "AGT-009",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "访问生产环境、真实数据、公网，删除数据，推送或部署，必须有运行时授权，不能只靠提示词里的一句话",
   "level": "hard",
   "check": "auto",
   "how": "在 Agent 的权限设置或沙箱里把这些操作设为需审批或禁止",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-009"
  },
  {
   "id": "AGT-010",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "安全边界靠沙箱、权限设置和分支保护强制，不靠在 AGENTS.md 里写「禁止」",
   "level": "hard",
   "check": "manual",
   "how": "评审时逐项核对：AGENTS.md 里写的每条「禁止」是否都有对应的强制手段",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-010"
  },
  {
   "id": "AGT-011",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "Agent 不得放松要求必过的检查、CI 配置、CODEOWNERS、质量阈值和受保护的测试；这些文件的改动必须由指定负责人批准",
   "level": "hard",
   "check": "auto",
   "how": "把这些路径写进 CODEOWNERS，并在分支保护里要求负责人批准",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-011"
  },
  {
   "id": "AGT-012",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "因业务行为变化而修改测试时，写明旧预期、新预期和依据",
   "level": "soft",
   "check": "semi",
   "how": "CI 发现测试文件被改时，要求 PR 描述里有对应说明",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-012"
  },
  {
   "id": "AGT-013",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "未运行或失败的检查不得报告为通过；检查结果必须对应最终提交",
   "level": "hard",
   "check": "semi",
   "how": "合并前把 Agent 报告的检查结果和 CI 在最终提交上的结果对照",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-013"
  },
  {
   "id": "AGT-014",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "Agent 自己的检查不等于验收；合并必须有可信 CI 通过和授权评审",
   "level": "hard",
   "check": "auto",
   "how": "分支保护要求 CI 通过且至少一名非作者批准",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-014"
  },
  {
   "id": "AGT-015",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "交付时写清改动文件、起止提交编号、跑了哪些检查及结果、没跑什么及原因、风险和回滚方式",
   "level": "hard",
   "check": "semi",
   "how": "PR 模板固定这些栏目，CI 检查栏目非空",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-015"
  },
  {
   "id": "AGT-016",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "改动取最小；优先复用已有模块和依赖，新增依赖要写理由",
   "level": "soft",
   "check": "semi",
   "how": "评审时看依赖清单的变化和无关改动",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-016"
  },
  {
   "id": "AGT-017",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "按风险决定流程：小改动直接做；跨模块或有外部副作用的改动先写简短方案再动手",
   "level": "soft",
   "check": "manual",
   "how": "任务描述里标出风险等级，评审时抽查",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-017"
  },
  {
   "id": "AGT-018",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "验收标准缺失或互相矛盾时，先问清楚，不自己补",
   "level": "soft",
   "check": "manual",
   "how": "评审时看 Agent 是否记录了澄清问题",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-018"
  },
  {
   "id": "AGT-019",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "规则互相冲突又判断不了时，停下高风险操作，报告冲突的两条规则及出处",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查 Agent 的报告",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-019"
  },
  {
   "id": "AGT-020",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "上下文压缩或交接时，保留目标、范围、已做决定、改动状态、证据、未决风险和下一步",
   "level": "soft",
   "check": "manual",
   "how": "抽查交接记录是否包含这些项",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-020"
  },
  {
   "id": "AGT-021",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "Agent 积累的经验不直接写回权威文档，要走 PR 和评审",
   "level": "soft",
   "check": "semi",
   "how": "权威文档路径写进 CODEOWNERS",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-021"
  },
  {
   "id": "AGT-022",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "技能的 SKILL.md 必须符合 Agent Skills 规范：name 与目录名一致、只用小写字母数字和连字符；description 写清做什么和何时用",
   "level": "hard",
   "check": "auto",
   "how": "用 schema 校验 frontmatter",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-022"
  },
  {
   "id": "AGT-023",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "技能描述写明何时用、何时不用，并维护一组应触发和不应触发的测试问题",
   "level": "soft",
   "check": "semi",
   "how": "每次修改描述后跑正反触发测试，记录命中情况",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-023"
  },
  {
   "id": "AGT-024",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "技能不授予任何额外权限；安装前审查来源和脚本，不用「下载后直接执行」的方式安装",
   "level": "hard",
   "check": "manual",
   "how": "安装或更新技能时审查其脚本和来源，记录版本或提交编号",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-024"
  },
  {
   "id": "AGT-025",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "必须始终遵守的规则写进入口文件或强制手段，不放进可选技能",
   "level": "soft",
   "check": "manual",
   "how": "评审技能时检查其中是否有「必须」类规则",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-025"
  },
  {
   "id": "AGT-026",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "并行写代码的每个 Agent 使用独立的工作树和分支，不同时改同一个工作树",
   "level": "hard",
   "check": "auto",
   "how": "编排工具分配工作树；用 `git worktree list` 检查",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-026"
  },
  {
   "id": "AGT-027",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "多 Agent 先并行做只读的调研和评审；只有文件范围不重叠时才并行写",
   "level": "soft",
   "check": "manual",
   "how": "编排前列出每个 Agent 负责的文件范围",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-027"
  },
  {
   "id": "AGT-028",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "下游只接收带提交编号和检查结果的上游产物，不把其他 Agent 的文字结论当成已验证事实",
   "level": "soft",
   "check": "semi",
   "how": "交接记录必须引用提交编号和检查结果",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-028"
  },
  {
   "id": "AGT-029",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "高风险改动必须由非作者评审，评审者不能修改自己正在判定的验收测试",
   "level": "hard",
   "check": "auto",
   "how": "分支保护要求非作者批准；验收测试路径写进 CODEOWNERS",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-029"
  },
  {
   "id": "AGT-030",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "修改 Agent 配置（规则文件、技能、模型、工具）前后，在固定的任务集上对比结果",
   "level": "soft",
   "check": "semi",
   "how": "任务集覆盖应该做的、不该做的、夹带注入的、出错恢复的；记录配置版本和结果",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-030"
  },
  {
   "id": "AGT-031",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "规则文件的修改走 PR 和评审，可以回滚；自动巡检工具只提建议，不直接生效",
   "level": "soft",
   "check": "auto",
   "how": "规则文件路径写进 CODEOWNERS",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-031"
  },
  {
   "id": "AGT-032",
   "topic": "23",
   "topicTitle": "编码 Agent 规范",
   "title": "要求断网的环境里，Agent 不得安装公网软件、上报遥测或调用云端模型，除非事先批准",
   "level": "hard",
   "check": "auto",
   "how": "网络出口默认拒绝；用断网验收和 DNS 记录检查",
   "applies": [
    "kind:agent",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/agents/#agt-032"
  },
  {
   "id": "LLM-001",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "业务代码通过配置里的逻辑名调用模型，不在代码里散落厂商模型 ID",
   "level": "soft",
   "check": "auto",
   "how": "CI 用正则或语法树规则扫描业务代码中的厂商模型 ID 字面量，只允许出现在模型配置文件里",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-001"
  },
  {
   "id": "LLM-002",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "生产配置固定到厂商的具体模型快照版本，不使用会自动指向新模型的别名",
   "level": "hard",
   "check": "auto",
   "how": "配置 schema 拒绝 `latest` 一类的浮动别名和不带版本的模型名；CI 校验",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-002"
  },
  {
   "id": "LLM-003",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "选模型按自己任务的评测结果，同时比较质量、成本、时延、数据合规和可维护性；不按排行榜或品牌",
   "level": "soft",
   "check": "manual",
   "how": "选型记录（如 ADR）里附评测数据集版本、候选模型版本和各项结果",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-003"
  },
  {
   "id": "LLM-004",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "更换模型、提示词、工具定义、检索索引或 embedding 模型，必须先在固定评测集上和当前线上版本配对比较，通过后才能上线",
   "level": "hard",
   "check": "semi",
   "how": "CI 在相关配置或文件变更时自动跑回归评测，结果作为合并或发布的必过检查；关键指标退化需负责人签字",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-004"
  },
  {
   "id": "LLM-005",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "对每个用到的厂商、模型和接口组合，实测它支持哪些能力，不假设「兼容某某接口」就全部兼容",
   "level": "soft",
   "check": "semi",
   "how": "维护一份能力清单（输入类型、上下文长度、工具调用、结构化输出、流式、可用区域），并用一组兼容性用例逐项验证",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-005"
  },
  {
   "id": "LLM-006",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "每次模型调用必须设置超时、支持取消，并限制并发",
   "level": "hard",
   "check": "semi",
   "how": "所有调用经过统一封装，静态检查不允许绕过封装直接调用 SDK；故障注入测试验证超时和取消生效",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-006"
  },
  {
   "id": "LLM-007",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "重试只针对连接失败、限流和服务端暂时性错误，带指数退避和随机抖动、次数上限和总时限，并遵守 Retry-After",
   "level": "soft",
   "check": "semi",
   "how": "故障注入测试覆盖 401、403、429、5xx、连接中断；检查不可重试的错误没有被重试",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-007"
  },
  {
   "id": "LLM-008",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "流式输出已经发给用户、或工具已经执行之后，不得自动重放请求；要返回「执行状态未知」，走查询或补偿",
   "level": "hard",
   "check": "semi",
   "how": "集成测试模拟流中途断开和工具执行后超时，检查没有重复输出和重复副作用",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-008"
  },
  {
   "id": "LLM-009",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "降级或切换到备用模型时，备用模型必须满足同一任务的能力要求和数据合规策略；模型拒答不得换更宽松的模型重试",
   "level": "hard",
   "check": "semi",
   "how": "降级策略写进配置，测试覆盖「备用模型不在批准的厂商或区域」时拒绝切换",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-009"
  },
  {
   "id": "LLM-010",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "模型输出在写库或执行前，必须通过 schema 校验和业务约束校验，并显式处理拒答、截断和无效输出",
   "level": "hard",
   "check": "auto",
   "how": "单元测试覆盖合法、缺字段、越界、拒答、截断五类输出；静态检查禁止解析后不校验直接使用",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-010"
  },
  {
   "id": "LLM-011",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "工具在执行侧独立校验调用者身份、权限、目标资源和参数；工具定义和模型的话都不算授权",
   "level": "hard",
   "check": "semi",
   "how": "每个工具都有越权负例测试（换用户、换租户、换资源）；评审检查授权逻辑不依赖模型输出",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-011"
  },
  {
   "id": "LLM-012",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "有副作用的写操作工具（付款、删除、发布、发送、改权限）必须有人工审批或确定性规则放行，带幂等键，并留审计记录",
   "level": "hard",
   "check": "semi",
   "how": "工具注册时标注是否有副作用；测试检查未审批时不执行、重复调用只生效一次、审计记录齐全",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-012"
  },
  {
   "id": "LLM-013",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "每个任务只给模型完成它所需的最少工具和最小权限；只读和读写工具分开",
   "level": "soft",
   "check": "manual",
   "how": "评审时对照任务列出的工具清单，检查有没有用不到的工具或过大的权限",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-013"
  },
  {
   "id": "LLM-014",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "接入第三方 MCP 服务或工具前，审查来源、固定版本；把它的工具描述和返回内容当作不可信数据",
   "level": "soft",
   "check": "manual",
   "how": "接入时记录来源、版本和审查人；回归评测里放入夹带指令的工具描述和工具返回",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-014"
  },
  {
   "id": "LLM-015",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "生产提示词放在版本库里，和代码一起评审；改动附评测结果",
   "level": "soft",
   "check": "semi",
   "how": "提示词文件路径写进 CODEOWNERS；CI 在提示词变更时要求有回归评测结果",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-015"
  },
  {
   "id": "LLM-016",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "运行时按不可变的版本号或内容哈希加载提示词，调用记录里写明所用版本",
   "level": "hard",
   "check": "auto",
   "how": "加载器只接受带版本或哈希的引用；链路追踪契约测试检查提示词版本字段存在",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-016"
  },
  {
   "id": "LLM-017",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "用户输入、检索内容、工具返回通过模板变量放进提示词的独立区块，不拼接进系统指令",
   "level": "hard",
   "check": "semi",
   "how": "静态检查系统提示词构造处没有字符串拼接外部内容；注入样本回归测试",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-017"
  },
  {
   "id": "LLM-018",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "每类模型任务都有一份版本化的离线评测集，覆盖正常、边界、对抗、应拒答、多轮、工具调用和真实线上分布",
   "level": "soft",
   "check": "semi",
   "how": "评测集放版本库并记录来源和标注人；CI 统计各类样本是否齐全",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-018"
  },
  {
   "id": "LLM-019",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "能用确定性断言判定的项（格式、字段、引用编号、工具参数、拒答与否），用断言判定，不交给模型评审",
   "level": "soft",
   "check": "semi",
   "how": "评审评测配置，检查客观项是否用了断言",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-019"
  },
  {
   "id": "LLM-020",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "用模型当评审前，先在人工标注的样本上校准一致性；没校准的不作为发布门禁；高风险变更和扩大模型权限不能只凭模型评审分数放行",
   "level": "hard",
   "check": "manual",
   "how": "评测报告里附评审模型与人工标注的一致性结果；评审时核对门禁里用到的每个模型评审项都有校准记录",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-020"
  },
  {
   "id": "LLM-021",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "上线后按授权比例抽样线上调用做质量检查，结合用户纠错和分布漂移，发现的问题补回评测集",
   "level": "soft",
   "check": "manual",
   "how": "定期检查抽样记录和评测集的更新记录",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-021"
  },
  {
   "id": "LLM-022",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "先确认需要 RAG：能直接查库、调已有接口、或内容能直接放进提示词的，不做向量检索",
   "level": "soft",
   "check": "manual",
   "how": "方案评审时写明为什么不能用直接查询，并附对照实验结果",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-022"
  },
  {
   "id": "LLM-023",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "RAG 在检索时就按当前用户和租户的权限过滤，不在拿到结果后再过滤",
   "level": "hard",
   "check": "auto",
   "how": "越权负例测试：用无权用户查询有权文档的独有内容，检查检索结果和回答里都没有",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-023"
  },
  {
   "id": "LLM-024",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "源文档删除、权限变更或改版后，必须在约定时间内更新索引；更新前的旧片段不得返回",
   "level": "hard",
   "check": "semi",
   "how": "集成测试删除或收回一份文档后再查询，检查不再命中；监控索引与源数据的同步延迟",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-024"
  },
  {
   "id": "LLM-025",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "切块按标题、段落、表格、代码块等结构边界切，每块保存来源编号、版本、位置、权限标签和 embedding 模型版本",
   "level": "soft",
   "check": "semi",
   "how": "索引 schema 校验必填元数据；切块参数通过离线实验确定并记录",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-025"
  },
  {
   "id": "LLM-026",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "检索从关键词检索（如 BM25）基线开始，按评测结果再加向量、混合检索和重排",
   "level": "soft",
   "check": "semi",
   "how": "每加一层都和上一层做对照实验，记录召回和成本变化",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-026"
  },
  {
   "id": "LLM-027",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "启用引用时，引用由服务端根据实际检索到的片段编号生成，并校验被引用片段确实支持该说法；不让模型自己写链接、页码或行号",
   "level": "hard",
   "check": "auto",
   "how": "引用完整性测试：每个引用都能反查到本次检索结果中的片段，且来源版本未过期",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-027"
  },
  {
   "id": "LLM-028",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "RAG 分开评测检索和生成：检索看召回率、排序质量，生成看正确性、忠实度、引用准确率和该拒答时是否拒答",
   "level": "soft",
   "check": "semi",
   "how": "评测报告分别列出检索指标和生成指标，以及越权率和时延",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-028"
  },
  {
   "id": "LLM-029",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "数据发给外部模型前，按数据分级确定允许的厂商、部署区域和日志级别；更换厂商或区域须数据负责人批准",
   "level": "hard",
   "check": "manual",
   "how": "模型配置里每个逻辑名标明允许的数据级别；评审时核对变更有数据负责人的批准记录",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-029"
  },
  {
   "id": "LLM-030",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "浏览器、移动端和桌面客户端不持有模型厂商的长期密钥；客户端经自己的后端或网关调用，用短期、限范围的令牌",
   "level": "hard",
   "check": "auto",
   "how": "对前端构建产物和客户端安装包跑密钥扫描",
   "applies": [
    "kind:llm",
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-030"
  },
  {
   "id": "LLM-031",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "模型成本按来源分开记：厂商账单、接口返回的用量、网关或内部估算、订阅额度和本地算力分摊，不混成一个数",
   "level": "soft",
   "check": "semi",
   "how": "成本记录带来源字段；定期用厂商账单对账，记录估算偏差",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-031"
  },
  {
   "id": "LLM-032",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "声称有硬预算上限时，必须在调用前原子预留最大可能花费，完成后按实际用量结算；并对单次请求限制最大输出长度和最大工具轮数",
   "level": "hard",
   "check": "semi",
   "how": "并发竞争测试：同时发起超出预算的请求，检查不会透支",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-032"
  },
  {
   "id": "LLM-033",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "长提示词把稳定内容放在前面以利用厂商的提示缓存，并用实际账单核对缓存是否真的省钱",
   "level": "soft",
   "check": "semi",
   "how": "监控缓存读写 token 和命中率，和账单对账；提示词变更后检查缓存命中和预算变化",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-033"
  },
  {
   "id": "LLM-034",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "应用自己做的响应缓存，缓存键必须包含租户、权限范围、模型版本、提示词版本和输入；涉及个人或敏感数据的结果默认不缓存",
   "level": "hard",
   "check": "auto",
   "how": "安全测试用两个租户发送相同请求，检查不会命中对方的缓存",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-034"
  },
  {
   "id": "LLM-035",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "日志和链路追踪默认不记录原始提示词、模型输出、工具参数和检索片段；需要时经授权采样、脱敏并限定保留期",
   "level": "hard",
   "check": "semi",
   "how": "日志抽样扫描敏感字段和个人信息；检查观测平台的采集配置",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-035"
  },
  {
   "id": "LLM-036",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "链路追踪覆盖请求、模型调用、工具调用和检索，记录模型版本、提示词版本、token 用量、首字耗时、重试和降级原因",
   "level": "soft",
   "check": "auto",
   "how": "契约测试检查各类调用都产生带这些字段的 span",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-036"
  },
  {
   "id": "LLM-037",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "本地推理服务（Ollama、vLLM 等）不直接暴露在公网或不受控网络上；必须放在带认证的网关或内网之后",
   "level": "hard",
   "check": "auto",
   "how": "部署检查监听地址；外部端口扫描确认推理端口不可从不受控网络访问",
   "applies": [
    "kind:llm",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-037"
  },
  {
   "id": "LLM-038",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "本地推理选型和上线用自己的模型和真实负载实测，记录引擎版本、权重哈希、量化格式、硬件、驱动、上下文长度和并发",
   "level": "soft",
   "check": "semi",
   "how": "基准测试结果作为文件存档，包含上述字段和首字耗时、吞吐、显存峰值、错误率",
   "applies": [
    "kind:llm",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-038"
  },
  {
   "id": "LLM-039",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "使用开源模型权重前，单独核查权重的许可条款；推理引擎的许可证不代表权重的许可",
   "level": "hard",
   "check": "manual",
   "how": "依赖清单里每个模型权重都有许可条款记录和核查人",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-039"
  },
  {
   "id": "LLM-040",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "声称支持离线或私有化的版本，必须在断网环境下完成冷启动、推理、评测和故障恢复验收",
   "level": "hard",
   "check": "auto",
   "how": "发版流程在断网的隔离环境里跑验收脚本，并检查没有对外连接",
   "applies": [
    "kind:llm",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-040"
  },
  {
   "id": "LLM-041",
   "topic": "24",
   "topicTitle": "模型与大模型工程",
   "title": "先用官方 SDK 加校验库实现；有明确需要时再引入编排框架，不为一次结构化输出引入大型框架",
   "level": "soft",
   "check": "manual",
   "how": "引入框架的 PR 写明它解决了官方 SDK 解决不了的哪个问题",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/llm/#llm-041"
  },
  {
   "id": "SEC-001",
   "topic": "25",
   "topicTitle": "安全",
   "title": "新增对外入口、跨信任边界、处理不可信文件或 URL、新增有副作用的模型工具、处理敏感数据之前，先在仓库里写一份轻量威胁模型",
   "level": "soft",
   "check": "manual",
   "how": "PR 模板加「是否触及上述情况」勾选项；勾选时评审者检查威胁模型文件是否新增或更新",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-001"
  },
  {
   "id": "SEC-002",
   "topic": "25",
   "topicTitle": "安全",
   "title": "以 OWASP ASVS 作为可验证的安全需求来源；Top 10 类清单只做覆盖检查，不当验收标准",
   "level": "soft",
   "check": "manual",
   "how": "安全评审时检查需求或测试是否引用 ASVS 条目编号，而不是只写「符合 Top 10」",
   "applies": [
    "kind:service",
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-002"
  },
  {
   "id": "SEC-003",
   "topic": "25",
   "topicTitle": "安全",
   "title": "每条安全例外（抑制规则、忽略漏洞、放宽门禁）必须写明负责人、原因、补偿措施和到期日；不允许永久抑制",
   "level": "hard",
   "check": "auto",
   "how": "CI 校验例外文件（如 .gitleaksignore、osv-scanner.toml 的忽略项、.trivyignore）每条都有到期日和理由，过期即失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-003"
  },
  {
   "id": "SEC-004",
   "topic": "25",
   "topicTitle": "安全",
   "title": "安全检查失败不得被吞掉：安全任务禁止用 `|| true`、continue-on-error 之类放行；扫描出错或漏洞库过期时结果记为「不确定」，不记为通过",
   "level": "hard",
   "check": "auto",
   "how": "用脚本或 Actions 静态检查查找安全任务里的 `|| true`、`continue-on-error: true`；扫描器非零退出码必须让任务失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-004"
  },
  {
   "id": "SEC-005",
   "topic": "25",
   "topicTitle": "安全",
   "title": "对外发布或交付的仓库必须有 SECURITY.md，写明私密报告渠道和支持的版本；未修复的漏洞不在公开 issue、提交信息或 PR 描述里披露细节",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查 SECURITY.md 存在且含联系方式；开启代码托管平台的私密漏洞报告功能",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-005"
  },
  {
   "id": "SEC-006",
   "topic": "25",
   "topicTitle": "安全",
   "title": "订阅所用语言生态、框架、基础镜像和安全工具自身的安全公告，并开启自动依赖安全更新",
   "level": "soft",
   "check": "semi",
   "how": "仓库开启 Dependabot 或 Renovate 的安全更新；关键组件的公告列表记录在仓库文档里",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-006"
  },
  {
   "id": "SEC-007",
   "topic": "25",
   "topicTitle": "安全",
   "title": "漏洞按「是否已被利用、严重度、是否可达、暴露面」分流并给出修复期限；不只看 CVSS 分数",
   "level": "soft",
   "check": "semi",
   "how": "漏洞工单记录分流依据和期限；定期报表列出超期项",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-007"
  },
  {
   "id": "SEC-008",
   "topic": "25",
   "topicTitle": "安全",
   "title": "主动 DAST 扫描和渗透测试只对明确授权的测试环境运行，固定目标清单和速率",
   "level": "hard",
   "check": "manual",
   "how": "扫描任务配置目标白名单并只在标记为测试的环境运行；评审时核对授权记录",
   "applies": [
    "kind:service",
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-008"
  },
  {
   "id": "SEC-009",
   "topic": "25",
   "topicTitle": "安全",
   "title": "真实密钥、令牌、密码不得出现在源码（含 Git 历史）、配置文件、镜像层、构建产物、日志、测试快照和提示词里",
   "level": "hard",
   "check": "auto",
   "how": "提交前钩子和 PR 跑密钥扫描（增量）；主分支定期扫全部历史；发布前扫镜像和制品",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-009"
  },
  {
   "id": "SEC-010",
   "topic": "25",
   "topicTitle": "安全",
   "title": "密钥不通过命令行参数传递，改用环境变量注入、权限 0600 的临时文件、标准输入或密钥管理系统",
   "level": "hard",
   "check": "semi",
   "how": "评审时检查脚本和 CI 配置里是否把密钥拼进命令行（如 `--password=`、`-p<值>`）；密钥扫描规则覆盖 CI 配置文件",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-010"
  },
  {
   "id": "SEC-011",
   "topic": "25",
   "topicTitle": "安全",
   "title": "运行时密钥由密钥管理系统或部署平台注入，不打包进镜像，不写进明文配置文件；必须随仓库版本化的少量配置用 SOPS 加密",
   "level": "hard",
   "check": "semi",
   "how": "镜像扫描检查层内是否含密钥；评审时检查配置文件里没有明文密钥",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-011"
  },
  {
   "id": "SEC-012",
   "topic": "25",
   "topicTitle": "安全",
   "title": "每个凭证登记负责人、用途、权限范围、到期时间和撤销方法；优先使用短期按需签发的凭证",
   "level": "soft",
   "check": "semi",
   "how": "凭证清单文件按 schema 校验；定期列出无负责人或已过期的条目",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-012"
  },
  {
   "id": "SEC-013",
   "topic": "25",
   "topicTitle": "安全",
   "title": "与生产相关的静态凭证要有演练过的轮换流程：签发新凭证、新旧并行、切换、撤销旧凭证、确认旧凭证失效",
   "level": "soft",
   "check": "manual",
   "how": "演练记录里有「旧凭证调用失败」的证据",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-013"
  },
  {
   "id": "SEC-014",
   "topic": "25",
   "topicTitle": "安全",
   "title": "疑似泄漏的密钥立即撤销或轮换，再清理 Git 历史、日志和制品里的副本；改写历史不能代替撤销",
   "level": "hard",
   "check": "manual",
   "how": "泄漏工单必须记录撤销时间和「旧密钥已失效」的验证结果",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-014"
  },
  {
   "id": "SEC-015",
   "topic": "25",
   "topicTitle": "安全",
   "title": "正式构建的依赖集合由锁文件和摘要决定；构建和部署不使用未审核的 latest 标签或浮动版本",
   "level": "hard",
   "check": "auto",
   "how": "CI 用锁文件安装（如 `npm ci`、`--frozen-lockfile`、`--require-hashes`），锁文件与清单不一致即失败；镜像引用检查是否带摘要",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-015"
  },
  {
   "id": "SEC-016",
   "topic": "25",
   "topicTitle": "安全",
   "title": "CI 里引用的第三方 Action、插件、扫描器和容器镜像必须按完整提交 SHA 或镜像摘要固定，不按可变标签引用",
   "level": "hard",
   "check": "auto",
   "how": "OpenSSF Scorecard 的 Pinned-Dependencies 检查，或用脚本检查工作流里 `uses:` 是否都是 40 位 SHA",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-016"
  },
  {
   "id": "SEC-017",
   "topic": "25",
   "topicTitle": "安全",
   "title": "来自外部贡献者或不可信分支的 PR，安装依赖和运行测试时不得拿到长期凭证和生产密钥；CI 令牌默认只读",
   "level": "hard",
   "check": "auto",
   "how": "检查工作流触发器（禁止在 pull_request_target 等有密钥的上下文里检出并运行 PR 代码）和令牌权限设置",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-017"
  },
  {
   "id": "SEC-018",
   "topic": "25",
   "topicTitle": "安全",
   "title": "新增依赖时评估来源、维护活跃度、安装脚本、许可证和是否有更轻的替代",
   "level": "soft",
   "check": "semi",
   "how": "CI 在依赖清单变化时提示评审；可参考 OpenSSF Scorecard 分数",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-018"
  },
  {
   "id": "SEC-019",
   "topic": "25",
   "topicTitle": "安全",
   "title": "依赖的新版本发布后先等一段冷却期再升级，紧急安全修复除外",
   "level": "soft",
   "check": "auto",
   "how": "用包管理器或依赖更新机器人的最小发布时长设置（具体配置见 10–15）",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-019"
  },
  {
   "id": "SEC-020",
   "topic": "25",
   "topicTitle": "安全",
   "title": "PR 对变更的依赖跑依赖漏洞扫描，主分支定期全量扫描；存在不可接受漏洞时阻断发布",
   "level": "hard",
   "check": "auto",
   "how": "CI 必过检查运行 OSV-Scanner（或语言专用工具）；阻断阈值写在安全策略里，例外走 SEC-003",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-020"
  },
  {
   "id": "SEC-021",
   "topic": "25",
   "topicTitle": "安全",
   "title": "容器镜像在构建时和镜像仓库里定期扫描，覆盖系统包和语言依赖；基础镜像按摘要固定",
   "level": "hard",
   "check": "auto",
   "how": "CI 对构建出的镜像运行 Trivy，镜像仓库按计划重扫；Dockerfile 检查 FROM 是否带摘要",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-021"
  },
  {
   "id": "SEC-022",
   "topic": "25",
   "topicTitle": "安全",
   "title": "对外发布或交付给客户的制品必须附带由构建产物生成的 SBOM（CycloneDX 或 SPDX）",
   "level": "hard",
   "check": "auto",
   "how": "发布流水线生成 SBOM 并校验格式，SBOM 与制品一起归档",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-022"
  },
  {
   "id": "SEC-023",
   "topic": "25",
   "topicTitle": "安全",
   "title": "容器镜像和对外发布的二进制必须签名；部署时按允许的签名身份加摘要验证，不信任标签",
   "level": "hard",
   "check": "auto",
   "how": "发布任务用 Cosign 签名；部署前或准入控制里执行验签，失败即拒绝",
   "applies": [
    "kind:service",
    "kind:onprem",
    "kind:cli",
    "kind:library"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-023"
  },
  {
   "id": "SEC-024",
   "topic": "25",
   "topicTitle": "安全",
   "title": "按 SLSA Build 轨道逐级生成构建来源证明（provenance），不口头宣称达到某个级别",
   "level": "soft",
   "check": "semi",
   "how": "发布物附带来源证明并在部署时校验；宣称的级别要有对应的构建平台证据",
   "applies": [
    "kind:library",
    "kind:cli",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-024"
  },
  {
   "id": "SEC-025",
   "topic": "25",
   "topicTitle": "安全",
   "title": "断网或私有化环境的漏洞库要本地镜像并记录库的更新日期；超过约定的陈旧期，扫描结果记为「不确定」",
   "level": "soft",
   "check": "auto",
   "how": "扫描任务读取本地漏洞库的生成时间，超期时任务失败或标为不确定",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-025"
  },
  {
   "id": "SEC-026",
   "topic": "25",
   "topicTitle": "安全",
   "title": "在 PR 上运行 SAST 的高置信度规则并阻断，全量规则定期运行",
   "level": "soft",
   "check": "auto",
   "how": "CI 必过检查运行 Semgrep 社区版引擎和语言专用工具；误报写进带到期日的例外",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-026"
  },
  {
   "id": "SEC-027",
   "topic": "25",
   "topicTitle": "安全",
   "title": "数据库查询必须参数化，动态表名和排序字段只能来自白名单映射；系统命令用参数数组调用固定程序，不拼接 shell 字符串",
   "level": "hard",
   "check": "semi",
   "how": "SAST 规则查找字符串拼接 SQL 和 `shell=True`、`exec` 拼接等写法；注入单测",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-027"
  },
  {
   "id": "SEC-028",
   "topic": "25",
   "topicTitle": "安全",
   "title": "不对不可信数据做可执行对象的反序列化；用 JSON 等纯数据格式，YAML 用安全加载器，禁止 pickle 和按任意类名实例化",
   "level": "hard",
   "check": "semi",
   "how": "SAST 规则查找 `pickle.loads`、`yaml.load`（非 safe）、Java 原生反序列化等调用",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-028"
  },
  {
   "id": "SEC-029",
   "topic": "25",
   "topicTitle": "安全",
   "title": "服务端按用户或模型给的 URL 发请求时，限定协议，解析域名后校验目标 IP（拒绝回环、内网、链路本地、云元数据地址），每次重定向都重新校验，并走受限的出口",
   "level": "hard",
   "check": "semi",
   "how": "测试集覆盖内网地址、IPv6、十进制 IP、重定向到内网、DNS 重绑定等用例；评审时检查是否只用正则校验 URL",
   "applies": [
    "kind:service",
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-029"
  },
  {
   "id": "SEC-030",
   "topic": "25",
   "topicTitle": "安全",
   "title": "输出到页面时按上下文编码，不渲染未清洗的 HTML 或 Markdown 原始 HTML；必须渲染富文本时用成熟的清洗库，并配置内容安全策略（CSP）作为纵深防御",
   "level": "hard",
   "check": "semi",
   "how": "lint 或 SAST 规则查找 `dangerouslySetInnerHTML`、`v-html`、`innerHTML` 等用法；检查响应头里有 CSP",
   "applies": [
    "platform:web",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-030"
  },
  {
   "id": "SEC-031",
   "topic": "25",
   "topicTitle": "安全",
   "title": "用 Cookie 维持会话的应用，会修改状态的接口必须有 CSRF 防护（框架自带的令牌或校验 Origin），不能只靠 SameSite",
   "level": "hard",
   "check": "semi",
   "how": "浏览器负例测试从第三方页面发起修改请求，确认被拒绝",
   "applies": [
    "platform:web",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-031"
  },
  {
   "id": "SEC-032",
   "topic": "25",
   "topicTitle": "安全",
   "title": "登录后跳转等重定向只允许相对路径或白名单内的域名",
   "level": "hard",
   "check": "semi",
   "how": "测试用 `//evil.example`、`https://evil.example`、编码后的协议、`user@host` 等输入，确认被拒绝",
   "applies": [
    "platform:web",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-032"
  },
  {
   "id": "SEC-033",
   "topic": "25",
   "topicTitle": "安全",
   "title": "上传入口必须鉴权，限制大小和数量，按文件内容识别类型（不信扩展名和 Content-Type），用生成的名字存到网站根目录之外，下载时以附件方式返回",
   "level": "hard",
   "check": "semi",
   "how": "测试集覆盖超大文件、伪造扩展名、路径穿越文件名、可执行内容；评审检查存储路径和下载响应头",
   "applies": [
    "kind:service",
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-033"
  },
  {
   "id": "SEC-034",
   "topic": "25",
   "topicTitle": "安全",
   "title": "生产环境关闭调试模式和目录浏览，CORS 只放行明确的来源，设置安全响应头；错误响应不返回堆栈、SQL 和密钥",
   "level": "hard",
   "check": "auto",
   "how": "ZAP 基线扫描检查响应头和错误信息；配置 lint 检查调试开关和 CORS 通配符",
   "applies": [
    "kind:service",
    "platform:web"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-034"
  },
  {
   "id": "SEC-035",
   "topic": "25",
   "topicTitle": "安全",
   "title": "进入模型的外部内容（网页、文件、检索结果、工具返回、邮件、其他 Agent 的消息）一律当数据；它们不能改变系统指令、权限或安全策略",
   "level": "hard",
   "check": "semi",
   "how": "回归测试集放入夹带指令的输入，检查模型没有越权调用工具，策略判定没有被改变",
   "applies": [
    "kind:llm",
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-035"
  },
  {
   "id": "SEC-036",
   "topic": "25",
   "topicTitle": "安全",
   "title": "模型输出进入命令、SQL、HTML、URL、文件路径或工具参数之前，按目标类型校验和编码，与处理用户输入一样",
   "level": "hard",
   "check": "semi",
   "how": "SAST 查找模型输出直接流入 `exec`、SQL 拼接、`innerHTML` 等危险调用；负例测试",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-036"
  },
  {
   "id": "SEC-037",
   "topic": "25",
   "topicTitle": "安全",
   "title": "有副作用的工具调用由模型之外的代码做授权判定和参数 schema 校验；高风险操作的人工审批绑定具体目标和参数，参数一变审批作废",
   "level": "hard",
   "check": "semi",
   "how": "测试审批后篡改参数、替换目标的用例，确认执行被拒；审批界面显示系统生成的真实差异，而不只是模型的解释",
   "applies": [
    "kind:llm",
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-037"
  },
  {
   "id": "SEC-038",
   "topic": "25",
   "topicTitle": "安全",
   "title": "发往外部模型服务的数据先分级；密钥不得发送，个人敏感数据和客户数据按策略脱敏或禁止发送；要求断网的环境不调用外部模型，除非事先批准",
   "level": "hard",
   "check": "semi",
   "how": "模型调用统一经过网关，网关按数据级别执行脱敏或拦截并记录发送的数据类别；断网环境用出口策略和 DNS 记录验证",
   "applies": [
    "kind:llm",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-038"
  },
  {
   "id": "SEC-039",
   "topic": "25",
   "topicTitle": "安全",
   "title": "把 MCP 服务、Agent 技能、插件、模型权重和提示词模板当作依赖管理：固定版本或摘要，变更要评审",
   "level": "soft",
   "check": "semi",
   "how": "清单文件记录版本和摘要；变更走 PR 评审",
   "applies": [
    "kind:llm",
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/security/#sec-039"
  },
  {
   "id": "OPS-001",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "每种受支持的部署形态写清支持的操作系统和 CPU 架构、资源下限、依赖和高可用边界；单机部署（含 Compose）不得承诺跨机高可用",
   "level": "hard",
   "check": "manual",
   "how": "评审部署文档或交付清单，逐项核对上述栏目；检查对外材料里单机形态没有「高可用」「故障自动转移」字样",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-001"
  },
  {
   "id": "OPS-002",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "只有需要跨主机故障转移、自动多节点伸缩或多团队工作负载隔离，并且有人能负责集群的升级和排障时，才引入 Kubernetes",
   "level": "soft",
   "check": "manual",
   "how": "引入 Kubernetes 的方案评审里回答四个问题：可用性目标、故障域、扩容方式、谁运维集群",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-002"
  },
  {
   "id": "OPS-003",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "云资源和集群配置用基础设施即代码（IaC）声明并入库；控制台上的手工改动不作为最终状态，定期检测漂移",
   "level": "soft",
   "check": "semi",
   "how": "定时跑 `tofu plan` / `terraform plan` 或 GitOps 控制器的差异检查，有差异就提工单",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-003"
  },
  {
   "id": "OPS-004",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "生产部署的镜像引用写到不可变的摘要（`@sha256:…`），不用 `latest` 或可移动的标签",
   "level": "hard",
   "check": "auto",
   "how": "CI 扫描 Helm values、渲染后的清单和 Compose 文件，镜像引用不带摘要即失败；集群里可再加准入策略拦截",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-004"
  },
  {
   "id": "OPS-005",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "每个发布版本出一份制品清单（各架构镜像摘要、Chart 包、依赖和校验和），安装和升级前先校验",
   "level": "hard",
   "check": "auto",
   "how": "安装脚本第一步按清单校验每个文件的 SHA-256 和镜像摘要，不一致就停止",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-005"
  },
  {
   "id": "OPS-006",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "同一 CPU 架构下，云上和私有化使用同一个镜像摘要；多架构用多架构清单（manifest list）发布同一版本，配置和外部依赖通过环境注入",
   "level": "soft",
   "check": "semi",
   "how": "对比各环境部署记录里的镜像摘要",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-006"
  },
  {
   "id": "OPS-007",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "运行阶段的基础镜像优先选不含 shell 和包管理器的最小镜像（如 distroless、Docker Hardened Images）；排障用 debug 变体或临时调试容器，不把调试工具打进生产镜像",
   "level": "soft",
   "check": "semi",
   "how": "评审时看最终阶段的 FROM；用镜像扫描结果对比包数量和漏洞数",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-007"
  },
  {
   "id": "OPS-008",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "容器以非 root 运行，禁止提权，去掉全部 Linux capabilities，seccomp 用运行时默认配置；不用 privileged、hostNetwork、hostPath，确需时写明原因和替代限制",
   "level": "hard",
   "check": "auto",
   "how": "Kubernetes 命名空间开启 Pod Security Admission 的 restricted 级别；Compose 文件用 CI 脚本检查 `user`、`cap_drop`、`security_opt`、`privileged`",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-008"
  },
  {
   "id": "OPS-009",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "区分 liveness、readiness、startup 探针：liveness 只检查进程本身是否卡死，不检查数据库等下游；readiness 反映能否接流量；启动慢的服务配 startup 探针",
   "level": "hard",
   "check": "semi",
   "how": "评审探针实现；集成测试里停掉下游依赖，确认服务变为未就绪而不是被反复重启",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-009"
  },
  {
   "id": "OPS-010",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "Compose 里依赖其他服务就绪的，给被依赖服务配 healthcheck，并用 `depends_on` 的 `condition service_healthy`；应用本身仍要能重试连接",
   "level": "soft",
   "check": "auto",
   "how": "CI 解析 Compose 文件，检查 depends_on 是否用了长格式和健康条件",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-010"
  },
  {
   "id": "OPS-011",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "收到 SIGTERM 后先标记未就绪、停止接新请求和新任务，在设定的期限内处理完或交还在途工作，再退出；停机期限不超过平台的强制终止时间",
   "level": "hard",
   "check": "semi",
   "how": "集成测试在长请求、流式连接、队列任务进行中发送 SIGTERM，检查无请求被截断、任务被重新投递",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-011"
  },
  {
   "id": "OPS-012",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "每个容器显式配置 CPU 和内存的请求与上限（或 Compose 的资源限制），数值来自压测或线上观测，不照抄示例",
   "level": "hard",
   "check": "auto",
   "how": "CI 用 kube-linter 或策略引擎检查清单里每个容器都有 requests 和 limits；评审时看数值依据",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-012"
  },
  {
   "id": "OPS-013",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "发布说明写清能从哪些版本直接升级、是否需要中转版本、不可逆的变更和回退限制",
   "level": "hard",
   "check": "manual",
   "how": "发布评审时核对发布说明里这四项都有",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-013"
  },
  {
   "id": "OPS-014",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "破坏性的数据结构或协议变更按「扩展 → 迁移 → 收缩」分步发布，收缩放在之后的版本；不把回滚镜像当作回滚数据",
   "level": "hard",
   "check": "semi",
   "how": "评审迁移计划时检查分步；CI 跑新旧版本混跑的兼容测试",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-014"
  },
  {
   "id": "OPS-015",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "线上发布分批进行；切换前记下关键指标基线，事先写好暂停和回退的条件，并确认回退步骤可用",
   "level": "soft",
   "check": "manual",
   "how": "发布记录里有基线、分批计划、暂停和回退条件；抽查回退是否演练过",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-015"
  },
  {
   "id": "OPS-016",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "功能开关通过 OpenFeature 这类标准接口读取；开关服务不可达时返回代码里的默认值继续运行，不阻塞启动和请求",
   "level": "hard",
   "check": "semi",
   "how": "集成测试里停掉开关服务，确认应用正常启动、按默认值运行",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-016"
  },
  {
   "id": "OPS-017",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "每个功能开关登记负责人、用途、默认值和计划删除的时间或版本，到期清理",
   "level": "soft",
   "check": "semi",
   "how": "开关清单入库，CI 列出超期未删的开关",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-017"
  },
  {
   "id": "OPS-018",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "写明支持哪些版本同时在维护、是否允许跳版本升级，不无限期兼容历史版本",
   "level": "soft",
   "check": "manual",
   "how": "支持矩阵入库，发布评审时更新",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-018"
  },
  {
   "id": "OPS-019",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "应用日志写到标准输出或标准错误，由平台采集；不把日志只留在容器内的文件里",
   "level": "hard",
   "check": "auto",
   "how": "部署配置检查里没有把日志只写容器内路径的配置；启动后确认采集端能收到日志",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-019"
  },
  {
   "id": "OPS-020",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "链路追踪和指标用 OpenTelemetry API 与 OTLP 协议上报，经 Collector 转发；业务代码不直接调用某一家后端的专有 SDK",
   "level": "soft",
   "check": "semi",
   "how": "依赖扫描查找业务模块里的厂商专有遥测 SDK",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-020"
  },
  {
   "id": "OPS-021",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "采用 OpenTelemetry 前查一下目标语言各信号的稳定级别；日志在该语言未稳定时，用语言自带的成熟日志库输出结构化日志，由 Collector 采集并带上追踪编号",
   "level": "soft",
   "check": "manual",
   "how": "方案评审时附上 OpenTelemetry 状态页的结论",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-021"
  },
  {
   "id": "OPS-022",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "可观测组件（Collector、日志采集、后端）故障或变慢时不得阻塞业务请求；Collector 配内存限制和批处理，宁可丢遥测也不拖垮业务",
   "level": "hard",
   "check": "semi",
   "how": "测试时停掉或限流可观测后端，确认业务请求的延迟和成功率不受影响",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-022"
  },
  {
   "id": "OPS-023",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "用户编号、请求编号、追踪编号、订单号这类取值无上限的字段，不做指标标签，也不做日志的索引标签",
   "level": "hard",
   "check": "semi",
   "how": "定期用 `promtool tsdb analyze` 或后端自带的基数统计找出序列数最多的标签；评审新增指标时检查标签",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-023"
  },
  {
   "id": "OPS-024",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "集中采集日志；按用途（排障、审计、合规）分别写明保留期和存储预算，定期对照实际用量复核",
   "level": "soft",
   "check": "manual",
   "how": "保留策略写进部署文档；每月或每季度看一次存储增长和费用",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-024"
  },
  {
   "id": "OPS-025",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "区分本地运维遥测和发往厂商的遥测：发往厂商的默认关闭、由客户显式开启；关闭外发不影响本地的健康检查、指标和审计",
   "level": "hard",
   "check": "auto",
   "how": "在断网或出口全拒绝的环境里运行，记录对外连接；检查默认配置里外发开关为关",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-025"
  },
  {
   "id": "OPS-026",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "关键服务按用户旅程定义 SLI（如下单成功率、关键接口延迟）和 SLO，写明统计窗口、排除范围和负责人",
   "level": "soft",
   "check": "semi",
   "how": "SLO 定义文件入库，CI 校验必填字段；每季度对照实际数据复核",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-026"
  },
  {
   "id": "OPS-027",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "呼人告警基于用户影响（如错误预算的多窗口燃烧率），不按单机 CPU、内存阈值呼人；资源类告警发工单或通知",
   "level": "soft",
   "check": "semi",
   "how": "评审告警规则，列出级别为呼人的规则逐条看触发条件",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-027"
  },
  {
   "id": "OPS-028",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "每条呼人告警都带负责人、事故等级和运行手册链接；没有运行手册的告警不得设为呼人",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查告警规则的 labels 和 annotations 里有 owner、severity、runbook_url，且链接可访问",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-028"
  },
  {
   "id": "OPS-029",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "告警路由做分组、抑制和静默，值班有主备和升级链；每月统计告警数量、可处置比例和重复告警，删改无动作的告警",
   "level": "soft",
   "check": "manual",
   "how": "查看告警路由配置和月度告警复盘记录",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-029"
  },
  {
   "id": "OPS-030",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "事故按 SEV1–SEV4 定级；SEV1、SEV2 指定事故指挥，分开处置和对外沟通的人，并维护一份实时更新的事故记录（时间线、影响面、决定）",
   "level": "soft",
   "check": "manual",
   "how": "抽查 SEV1、SEV2 事故记录是否有指挥人、时间线、影响范围和关键决定",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-030"
  },
  {
   "id": "OPS-031",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "SEV1、SEV2 事故结束后做复盘：对事不对人，写清触发条件、系统性原因、为什么没有提前发现；行动项有负责人、期限和验证方式，并跟踪到关闭",
   "level": "soft",
   "check": "semi",
   "how": "复盘文档模板固定这些栏目；行动项关联工单，定期列出超期未关的",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-031"
  },
  {
   "id": "OPS-032",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "运行手册入库并版本化，写明适用的告警、前置检查、操作步骤、结果验证、停止条件和回退；每次事故或演练后更新",
   "level": "soft",
   "check": "semi",
   "how": "CI 检查运行手册包含这些小节；告警的 runbook_url 指向存在的文件",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-032"
  },
  {
   "id": "OPS-033",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "备份作业纳入监控，失败或超时就告警；恢复演练按 17 定的频率在隔离环境执行，恢复后跑业务冒烟，记录实际恢复时间和数据损失",
   "level": "hard",
   "check": "manual",
   "how": "检查备份告警规则存在；查看最近一次演练记录的日期、步骤、冒烟结果和实测 RPO/RTO",
   "applies": [
    "kind:service",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-033"
  },
  {
   "id": "OPS-034",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "重要发布或预期流量增长前做容量测试，写清负载模型、p95/p99 延迟、瓶颈所在和剩余余量；用了自动伸缩的，用负载试验验证扩缩结果，不只检查配置存在",
   "level": "soft",
   "check": "manual",
   "how": "保留压测报告（含自动伸缩在稳态、突增、回落三个阶段的副本数和延迟），发布评审时引用",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-034"
  },
  {
   "id": "OPS-035",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "私有化离线版必须在完全断开公网出口的环境里验收安装、升级、回退和业务冒烟，并确认没有对外连接",
   "level": "hard",
   "check": "auto",
   "how": "在出口全拒绝的网络里跑安装和升级流水线，记录 DNS 查询和出站连接，非零即失败",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-035"
  },
  {
   "id": "OPS-036",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "离线安装包自带全部所需内容：各架构镜像（OCI 归档或内网仓库同步包）、Chart 和依赖、迁移脚本、校验和、安装升级回退恢复手册、预检和冒烟脚本；安装和运行时不下载包外资源",
   "level": "hard",
   "check": "auto",
   "how": "扫描安装脚本和配置里的外部 URL；断网验收（见 OPS-035）",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-036"
  },
  {
   "id": "OPS-037",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "私有化安装前运行预检，检查 CPU、内存、磁盘、端口、DNS、时间同步、证书链、CPU 架构和容器运行时，不满足就给出明确原因并停止；支持导入客户私有 CA 和替换证书，证书到期前告警",
   "level": "soft",
   "check": "auto",
   "how": "预检脚本输出结构化报告，安装流程在预检失败时退出",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-037"
  },
  {
   "id": "OPS-038",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "给客户的诊断包默认脱敏（不含密钥、令牌、私钥和业务数据），列出采集范围，客户可预览、删选后离线导出",
   "level": "hard",
   "check": "semi",
   "how": "对诊断包样本跑密钥扫描；评审采集清单",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-038"
  },
  {
   "id": "OPS-039",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "对外声明支持某个国产操作系统或 CPU 架构前，在该系统、内核、容器运行时和 CPU 的组合上实测安装和核心业务流程，并保留记录",
   "level": "hard",
   "check": "manual",
   "how": "评审兼容声明时核对每个组合都有实测记录",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-039"
  },
  {
   "id": "OPS-040",
   "topic": "26",
   "topicTitle": "运维部署与 SRE",
   "title": "Agent 参与生产运维时默认只读；写操作只能调用事先登记的、幂等且可回退的具名动作，经人批准并留审计；恢复数据、删除、改权限和网络边界不交给 Agent 自动执行",
   "level": "hard",
   "check": "auto",
   "how": "在 Agent 的运行时权限里只开放只读凭据和登记过的动作；审计日志抽查",
   "applies": [
    "kind:agent",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/ops/#ops-040"
  },
  {
   "id": "LGL-001",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "每个交付项目维护一份合规档案，写明法域、交付方式、各方角色、处理的数据类别、是否用外部模型、是否向公众提供 AI 内容、负责人",
   "level": "soft",
   "check": "auto",
   "how": "CI 用 JSON Schema 校验档案存在且必填字段非空；缺失时阻断发布",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-001"
  },
  {
   "id": "LGL-002",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "法律适用问题（备案、出境机制、copyleft 范围、GDPR 角色、AI Act 风险分类）标「需要法务确认」并由具名负责人签字，Agent 和自动检查不得判为通过",
   "level": "hard",
   "check": "semi",
   "how": "合规检查结果只有「通过 / 失败 / 需要法务确认」三种；CI 检查「需要法务确认」项都关联了签字记录，否则阻断发布",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-002"
  },
  {
   "id": "LGL-003",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "合规例外写明批准人、理由、证据、适用范围和到期日；到期后阻断发布，不跨项目或跨版本继承",
   "level": "soft",
   "check": "auto",
   "how": "例外文件用 schema 校验必填字段；CI 比较到期日，过期即失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-003"
  },
  {
   "id": "LGL-004",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "每次发布前对比上一版的依赖许可、模型与服务条款、子处理者、数据存放地域和处理目的，有变化就触发审查",
   "level": "soft",
   "check": "auto",
   "how": "发布流水线对基线清单做差异比较，差异项生成审查任务",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-004"
  },
  {
   "id": "LGL-005",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "合规说明文档写明依据（法规名、条款、版本或生效日期）、最后复核日期和下次复核日期",
   "level": "soft",
   "check": "auto",
   "how": "文档头部字段 lint",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-005"
  },
  {
   "id": "LGL-006",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "每个依赖登记使用方式：独立运行的工具，还是随产品分发、链接进产品、作为产品一部分对外提供服务的组件",
   "level": "hard",
   "check": "semi",
   "how": "依赖清单里 `usage` 字段必填；评审时核对镜像、安装包、前端打包产物里是否出现了标为「工具」的东西",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-006"
  },
  {
   "id": "LGL-007",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "随产品分发或链接进产品的组件按许可政策处理：宽松许可允许；弱 copyleft 需审查；GPL、AGPL 禁止，例外需要法务确认；源码可见类需审查，批准前阻断",
   "level": "hard",
   "check": "auto",
   "how": "在许可扫描器（ScanCode / ORT）的策略文件里配置允许、需审查、禁止三档；需审查和禁止项没有关联审批记录时 CI 失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-007"
  },
  {
   "id": "LGL-008",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "独立运行的工具按许可政策处理：宽松、弱 copyleft、GPL、AGPL 可直接使用；修改 AGPL 工具后对外提供网络服务、源码可见或商业许可的工具、无许可的工具需审查",
   "level": "hard",
   "check": "semi",
   "how": "许可扫描对标为「工具」的依赖使用单独的策略；需审查项要有审批记录",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-008"
  },
  {
   "id": "LGL-009",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "工具被打进产品镜像或安装包，或把自身代码（运行时库、模板、生成代码中的固定片段）复制进产物时，按组件重新判定许可",
   "level": "hard",
   "check": "semi",
   "how": "对发布产物的 SBOM 做扫描，出现标为「工具」的条目即报警；代码生成器的输出许可在引入时评审",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-009"
  },
  {
   "id": "LGL-010",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "无许可、许可未知、许可冲突的代码不得进入发布物，也不得按 MIT 或其他宽松许可推定",
   "level": "hard",
   "check": "auto",
   "how": "许可扫描结果为 NOASSERTION、Unknown、无 LICENSE 或表达式冲突时 CI 失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-010"
  },
  {
   "id": "LGL-011",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "SSPL、BSL、FSL、ELv2 等源码可见许可不称为开源；登记具体条款（附加使用授权、变更日期、竞争用途、托管限制），按版本审批",
   "level": "hard",
   "check": "semi",
   "how": "许可策略把这些标识列为需审查；审批记录写明版本和条款摘要，版本变化后旧审批失效",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-011"
  },
  {
   "id": "LGL-012",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "依赖登记精确版本、来源（purl 或仓库地址加提交）、SPDX 许可表达式、使用方式、分发方式、是否修改",
   "level": "hard",
   "check": "auto",
   "how": "从锁文件和产物 SBOM 生成清单，缺字段时 CI 失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-012"
  },
  {
   "id": "LGL-013",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "复制第三方代码片段或素材时记录来源链接、提交或版本、许可和署名；不复制无许可代码和竞品源码",
   "level": "hard",
   "check": "semi",
   "how": "PR 模板有「第三方来源」栏；许可扫描识别出与已知开源代码匹配的片段时要求补登记",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-013"
  },
  {
   "id": "LGL-014",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "仓库自己的源码文件用 SPDX 头或 REUSE 声明写明版权和许可",
   "level": "soft",
   "check": "auto",
   "how": "CI 运行 `reuse lint`",
   "applies": [
    "kind:library",
    "kind:cli"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-014"
  },
  {
   "id": "LGL-015",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "CI 运行许可扫描并按许可策略阻断；策略文件纳入版本管理，修改要指定负责人批准",
   "level": "hard",
   "check": "auto",
   "how": "每个 PR 跑许可扫描；策略文件路径写进 CODEOWNERS",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-015"
  },
  {
   "id": "LGL-016",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "对实际发布产物（容器镜像、安装包、App）生成带许可信息的 SBOM，并与产物摘要绑定，每个版本重新生成",
   "level": "hard",
   "check": "auto",
   "how": "发布流水线按镜像摘要生成 SBOM，校验可解析、覆盖产物且每个组件有许可字段",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-016"
  },
  {
   "id": "LGL-017",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "发布物附第三方声明文件、各组件许可原文和上游 NOTICE；触发源码义务时附对应源码或书面提供方式",
   "level": "hard",
   "check": "semi",
   "how": "发布校验检查声明文件覆盖 SBOM 中的全部组件、上游 NOTICE 原文没丢；源码义务由许可审查人确认",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-017"
  },
  {
   "id": "LGL-018",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "私有化和离线交付包内带齐许可原文、第三方声明和 SBOM，断网时本地可查看",
   "level": "hard",
   "check": "auto",
   "how": "在断网环境安装交付包，自动检查这些文件可以打开且与产物版本一致",
   "applies": [
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-018"
  },
  {
   "id": "LGL-019",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "字体、图标、图片、音视频、数据集和模型权重与代码分开登记许可、来源、版本和署名要求",
   "level": "hard",
   "check": "auto",
   "how": "素材清单 lint：仓库里的素材文件都在清单里，且许可字段非空",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-019"
  },
  {
   "id": "LGL-020",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "CC-NC、CC-ND 许可和来源不明的素材不进入商业发布物",
   "level": "hard",
   "check": "auto",
   "how": "素材许可策略把 NC、ND 和无来源条目设为禁止，命中时发布校验失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-020"
  },
  {
   "id": "LGL-021",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "模型权重、分词器、训练和评测数据集分别登记许可、使用政策、具体版本和哈希，不因框架代码是宽松许可就推定权重也是",
   "level": "hard",
   "check": "semi",
   "how": "模型清单 lint 检查必填字段；新模型引入时由许可审查人确认使用政策",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-021"
  },
  {
   "id": "LGL-022",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "每个持久化数据对象登记用途、数据类别与分级、负责人、来源、存放地域、接收方、保留期及起算事件、删除方式",
   "level": "hard",
   "check": "auto",
   "how": "数据清单 schema lint；数据库迁移或新存储出现未登记对象时 CI 失败",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-022"
  },
  {
   "id": "LGL-023",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "个人信息字段不得默认永久保存；每类绑定有限的保留期，依法必须保留的写明依据",
   "level": "hard",
   "check": "auto",
   "how": "schema lint：标为个人信息的对象必须有保留策略键，且不能是「永久」",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-023"
  },
  {
   "id": "LGL-024",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "新增个人信息字段写明用途并只收集该用途必需的；新增敏感个人信息、未成年人数据、重要数据或高风险用途时触发影响评估审查",
   "level": "hard",
   "check": "semi",
   "how": "CI 检测 schema 或数据清单中新增的敏感分类，自动创建审查任务；合并前要有评估记录或「不需要」的签字结论",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-024"
  },
  {
   "id": "LGL-025",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "按用途记录处理依据，不把「用户同意」当作所有处理的唯一依据；需要同意或单独同意的场景单独建模并支持撤回",
   "level": "soft",
   "check": "semi",
   "how": "数据清单的处理依据字段必填；评审时核对同意场景有记录、可撤回",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-025"
  },
  {
   "id": "LGL-026",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "删除覆盖主库、搜索索引、对象存储、缓存、Agent 记忆、导出副本和供应商副本；备份写明到期时间和恢复后重新删除的做法；删除状态可查",
   "level": "hard",
   "check": "auto",
   "how": "多存储集成测试：删除一个用户后，各存储里都查不到其数据；备份列入待到期清单",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-026"
  },
  {
   "id": "LGL-027",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "处理个人信息的产品提供查阅、复制、更正、删除、可携带等请求入口和负责人，记录每个请求的状态、时限和结果；时限按适用法规配置，不硬编码",
   "level": "hard",
   "check": "semi",
   "how": "端到端测试请求入口可用；请求流转记录有状态、截止时间和结果",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-027"
  },
  {
   "id": "LGL-028",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "日志按类别分别设保留期，不用同一个过期时间覆盖网络日志、向第三方提供数据的记录和 AI 标识例外记录",
   "level": "hard",
   "check": "auto",
   "how": "保留策略 lint：这几类日志各有独立策略，且不短于下列最低期限",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-028"
  },
  {
   "id": "LGL-029",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "客户的代码、文档、会话和个人信息发往外部模型或第三方服务前必须有策略批准；默认关闭，按供应商、地域、数据类别白名单放行",
   "level": "hard",
   "check": "auto",
   "how": "出站策略单元测试：离线和私有化配置下所有外发被拒绝；未登记的供应商或地域被拒绝",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-029"
  },
  {
   "id": "LGL-030",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "第三方服务和模型供应商登记服务地域、子处理者、训练使用、数据留存、人工审核访问，保存条款快照；任一项变化重新审查",
   "level": "soft",
   "check": "semi",
   "how": "供应商清单缺字段时阻断启用；条款快照与上次比对，有差异生成审查任务",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-030"
  },
  {
   "id": "LGL-031",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "数据流向登记写明接收方的实际所在地（含备份、日志、技术支持访问）；境外有接收或访问即触发出境审查，记录适用机制或豁免判定并由负责人签字",
   "level": "hard",
   "check": "semi",
   "how": "数据流向清单中出现境外地域时自动创建出境审查任务；启用前要有签字记录",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-031"
  },
  {
   "id": "LGL-032",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "个人信息出境人数按当年 1 月 1 日起累计、按自然人去重统计，敏感个人信息单独计数；不用请求次数代替，不拆分规避",
   "level": "hard",
   "check": "semi",
   "how": "统计逻辑写单元测试（同一个人多次请求只算一人）；统计结果由合规负责人复核",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-032"
  },
  {
   "id": "LGL-033",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "处理欧盟个人数据的产品记录控制者或处理者角色、处理依据、处理活动记录、接收方和第三国转移机制及证据",
   "level": "hard",
   "check": "semi",
   "how": "合规档案中法域含欧盟时，CI 检查这些记录存在；转移机制由法务确认",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-033"
  },
  {
   "id": "LGL-034",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "向中国境内公众提供生成式 AI 服务或功能前，触发备案、登记和安全评估的适用性审查；备案号和上线编号只能来自真实登记结果",
   "level": "hard",
   "check": "manual",
   "how": "发布审批清单包含适用性结论和签字；评审时核对页面展示的编号与登记记录一致",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-034"
  },
  {
   "id": "LGL-035",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "适用 AI 生成合成内容标识办法的服务，同时加显式标识和文件元数据隐式标识，复制、下载、导出时不丢失",
   "level": "hard",
   "check": "auto",
   "how": "用导出样例做自动测试：界面和导出文件都带显式标识，文件元数据带隐式标识",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-035"
  },
  {
   "id": "LGL-036",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "在欧盟提供或使用 AI 系统时，记录角色（提供者、部署者、通用模型提供者）和用途风险分类，并按各阶段适用日期准备交互告知、生成内容标记和高风险义务",
   "level": "hard",
   "check": "semi",
   "how": "合规档案中法域含欧盟且有 AI 功能时，CI 检查角色和风险分类字段；分类结论由法务确认",
   "applies": [
    "kind:llm"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-036"
  },
  {
   "id": "LGL-037",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "在欧盟市场提供含软件的产品的制造商，准备好在 24 小时内向 ENISA 单一报告平台报告被积极利用的漏洞和严重安全事件",
   "level": "hard",
   "check": "manual",
   "how": "演练记录：从发现被利用的漏洞到提交早期预警的流程、负责人、平台账号都已就绪",
   "applies": [
    "all"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-037"
  },
  {
   "id": "LGL-038",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "中国境内面向公众的 App 上架前判定 App 备案适用性；商店隐私披露（Apple 隐私信息、Google Play 数据安全表）与实际 SDK 数据流一致",
   "level": "hard",
   "check": "semi",
   "how": "发布清单检查备案结论；把 SDK 清单与商店申报逐项对账",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-038"
  },
  {
   "id": "LGL-039",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "面向加州居民的服务触发 CCPA 审查：删除、知情、更正、选择退出请求和全局隐私控制信号，自动化决策技术义务按阶段核对",
   "level": "soft",
   "check": "semi",
   "how": "合规档案法域含美国加州时创建审查任务；选择退出流程做端到端测试",
   "applies": [
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-039"
  },
  {
   "id": "LGL-040",
   "topic": "27",
   "topicTitle": "合规、隐私与许可",
   "title": "面向欧盟消费者的产品和服务触发欧洲无障碍法（EAA）适用性审查，技术要求按 09 执行",
   "level": "soft",
   "check": "manual",
   "how": "合规档案法域含欧盟且面向消费者时，发布清单包含 EAA 适用性结论",
   "applies": [
    "platform:web",
    "platform:mobile",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/compliance/#lgl-040"
  },
  {
   "id": "MOB-001",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "选定移动端技术栈前写决策记录，写明候选方案、取舍理由和推翻条件",
   "level": "soft",
   "check": "semi",
   "how": "CI 检查决策记录文件存在；评审时看是否比较了原生、跨平台和 Web/PWA",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-001"
  },
  {
   "id": "MOB-002",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "后台定位、蓝牙、相机实时处理、系统扩展是核心功能时，先在目标机型上验证原生能力，验证通过前不承诺跨端实现",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查有没有能力清单和真机验证记录（机型、系统版本、权限、前后台切换）",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-002"
  },
  {
   "id": "MOB-003",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "PWA 和小程序只用于轻量入口；需要可靠推送、后台运行或深度硬件访问时，不用它们替代原生 App",
   "level": "soft",
   "check": "manual",
   "how": "选型评审时对照需求里的后台、推送、硬件、存储容量要求",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-003"
  },
  {
   "id": "MOB-004",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "跨端共享接口契约、设计令牌和纯领域逻辑；不把代码共享率当指标，不强求各平台界面像素一致",
   "level": "soft",
   "check": "manual",
   "how": "评审共享模块：不依赖 DOM、Node、Android Context 等平台对象；交互遵循各平台的设计规范",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-004"
  },
  {
   "id": "MOB-005",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "要做鸿蒙原生应用时，单独建立 ArkTS 工程规范和构建流程，只共享接口契约、资源和设计令牌",
   "level": "soft",
   "check": "manual",
   "how": "评审时检查鸿蒙端是否有独立的依赖、签名、权限和推送方案，以及 Android 依赖的逐项替换清单",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-005"
  },
  {
   "id": "MOB-006",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "React Native 项目使用新架构；引入原生依赖前确认它支持新架构",
   "level": "hard",
   "check": "auto",
   "how": "Expo 项目在 CI 运行 `npx expo-doctor`；裸 React Native 项目对照 React Native Directory 的新架构标记",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-006"
  },
  {
   "id": "MOB-007",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "依赖用锁文件固定，CI 用冻结安装；Expo 项目用 `npx expo install` 添加依赖，并在 CI 运行 `npx expo install --check`",
   "level": "hard",
   "check": "auto",
   "how": "CI 用 `npm ci` / `pnpm install --frozen-lockfile` / `flutter pub get --enforce-lockfile`；Expo 项目加跑 `npx expo install --check`",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-007"
  },
  {
   "id": "MOB-008",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "引入跨平台插件或原生模块前，检查维护状态、Android 和 iOS 两端的支持情况和许可证",
   "level": "soft",
   "check": "semi",
   "how": "依赖评审清单：最近发布时间、未解决问题、两端是否都有实现、许可证；工具辅助可用 React Native Directory、pub.dev 评分",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-008"
  },
  {
   "id": "MOB-009",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "导航、状态管理、本地数据库各只用一套；引入第二套要写理由",
   "level": "soft",
   "check": "semi",
   "how": "依赖审查脚本列出同类库，命中多套时要求决策记录",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-009"
  },
  {
   "id": "MOB-010",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "业务数据存在 SQLite 等结构化数据库里，不用 AsyncStorage 这类键值存储存业务主数据",
   "level": "soft",
   "check": "semi",
   "how": "代码扫描键值存储的写入，评审是否存了列表或实体数据",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-010"
  },
  {
   "id": "MOB-011",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "登录用系统浏览器 + 授权码 + PKCE；不在 WebView 里收集用户密码",
   "level": "hard",
   "check": "semi",
   "how": "代码扫描登录流程是否用系统认证会话（AppAuth、expo-auth-session、ASWebAuthenticationSession、Custom Tabs）；E2E 覆盖登录",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-011"
  },
  {
   "id": "MOB-012",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "访问令牌、刷新令牌等凭据只存系统安全存储（Keychain、Android Keystore 的封装），不明文落盘、不写日志",
   "level": "hard",
   "check": "semi",
   "how": "代码扫描令牌写入普通存储和日志的调用；日志断言测试里查找令牌格式",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-012"
  },
  {
   "id": "MOB-013",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "客户端不内置永久 API 密钥或服务端密钥；需要调用第三方服务时经自己的服务端转发或换取短期凭据",
   "level": "hard",
   "check": "auto",
   "how": "对仓库和构建产物跑密钥扫描",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-013"
  },
  {
   "id": "MOB-014",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "生物识别只用于解锁本机保存的凭据，不作为服务端的身份验证依据",
   "level": "hard",
   "check": "manual",
   "how": "评审认证设计：服务端是否只认令牌或签名，而不是客户端上报的「已通过生物识别」",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-014"
  },
  {
   "id": "MOB-015",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "退出登录、切换账户、切换租户时，清除凭据、本地敏感缓存和推送令牌绑定",
   "level": "hard",
   "check": "semi",
   "how": "E2E 用例：退出后检查安全存储、本地库、图片缓存为空，服务端已解除推送令牌",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-015"
  },
  {
   "id": "MOB-016",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "启用证书固定时，必须固定多个公钥（含备用）并有轮换和应急恢复方案；没有这些就不启用",
   "level": "hard",
   "check": "manual",
   "how": "安全评审检查固定的公钥数量、轮换流程和固定失败时的恢复手段（远程配置关闭或强制升级）",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-016"
  },
  {
   "id": "MOB-017",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "需要离线写入时，先把本地实体和待发操作一起写入持久化队列（outbox），界面标出「待同步」，不显示为已提交",
   "level": "hard",
   "check": "semi",
   "how": "集成测试：断网写入后杀进程重启，检查数据和待发操作都还在，联网后被提交",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-017"
  },
  {
   "id": "MOB-018",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "每个离线操作带唯一的幂等键，服务端据此去重；重试用指数退避加随机抖动",
   "level": "hard",
   "check": "semi",
   "how": "契约测试：同一操作重复提交、乱序提交，服务端结果只生效一次",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-018"
  },
  {
   "id": "MOB-019",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "按数据类型写明冲突策略；金额、审批、不可逆操作不得用「最后写入者胜」",
   "level": "hard",
   "check": "manual",
   "how": "评审同步设计文档：每类实体的冲突策略（拒绝覆盖、字段级合并、转人工）和对应测试",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-019"
  },
  {
   "id": "MOB-020",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "删除用墓碑记录同步；增量同步的游标失效时能回退到全量",
   "level": "soft",
   "check": "semi",
   "how": "同步测试覆盖删除、游标过期、时钟偏差、重复和乱序事件",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-020"
  },
  {
   "id": "MOB-021",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "不在需求或验收中承诺固定间隔的后台同步；以进入前台、网络恢复、用户操作作为同步时机",
   "level": "hard",
   "check": "manual",
   "how": "评审需求和验收标准里有没有「每 N 分钟后台同步」之类的表述",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-021"
  },
  {
   "id": "MOB-022",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "有本地数据库的应用，每次升级用旧版本的数据样本测试迁移",
   "level": "hard",
   "check": "auto",
   "how": "CI 里保留各历史 schema 版本的数据样本，逐个跑迁移到当前版本并校验",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-022"
  },
  {
   "id": "MOB-023",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "不承诺推送必达；重要业务提供应用内待办或消息列表，打开应用时主动拉取",
   "level": "hard",
   "check": "manual",
   "how": "验收文档检查：关闭通知权限、断网、应用被系统终止时，用户打开应用仍能看到待处理事项",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-023"
  },
  {
   "id": "MOB-024",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "面向中国大陆 Android 用户时，接入主要厂商推送通道（华为、荣耀、小米、OPPO、vivo 等）或经评估的聚合推送 SDK；鸿蒙原生用 Push Kit",
   "level": "soft",
   "check": "manual",
   "how": "发布前在各厂商真机上验证通知送达和点击跳转",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-024"
  },
  {
   "id": "MOB-025",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "推送标题和正文不含敏感业务内容，只带标识和中性提示，打开应用后再拉取详情",
   "level": "hard",
   "check": "semi",
   "how": "推送载荷的 schema 校验禁止正文字段；安全测试检查锁屏显示",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-025"
  },
  {
   "id": "MOB-026",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "接入崩溃上报，覆盖原生崩溃和 JS/Dart 未捕获异常；发布时上传符号表和 source map",
   "level": "soft",
   "check": "auto",
   "how": "发布流水线检查符号表和 source map 已上传，并用测试崩溃验证能还原堆栈",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-026"
  },
  {
   "id": "MOB-027",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "崩溃和性能数据上传前脱敏，不含令牌、用户正文、定位原始值；私有化部署时能关闭第三方上报或改指向客户自建的服务",
   "level": "hard",
   "check": "semi",
   "how": "出站流量测试或抓包检查上报内容；配置检查上报地址可配置",
   "applies": [
    "platform:mobile",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-027"
  },
  {
   "id": "MOB-028",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "在 Release 构建上监控启动耗时、卡顿和应用无响应（ANR）、同步积压和待发队列失败率",
   "level": "soft",
   "check": "semi",
   "how": "监控看板有这些指标；性能测试用 Release 包而不是 Debug 包",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-028"
  },
  {
   "id": "MOB-029",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "热更新（OTA）只更新脚本和资源；改原生代码、新增权限或原生依赖时必须发新安装包并过商店审核",
   "level": "hard",
   "check": "semi",
   "how": "发布流水线比对原生部分的指纹，有变化时阻止走 OTA",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-029"
  },
  {
   "id": "MOB-030",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "热更新不得改变应用的主要用途，不得绕过审核上线新的主要功能",
   "level": "hard",
   "check": "manual",
   "how": "发布评审检查每次 OTA 的改动说明，涉及新功能的走安装包发版",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-030"
  },
  {
   "id": "MOB-031",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "使用 EAS Update 时用 runtimeVersion 约束兼容性，原生部分变化必须改 runtimeVersion",
   "level": "hard",
   "check": "auto",
   "how": "用 `runtimeVersion.policy` 设为 `fingerprint`，由工具按原生部分自动计算",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-031"
  },
  {
   "id": "MOB-032",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "OTA 更新分批发布、可回滚，每次记录对应的安装包版本、runtimeVersion 和提交编号",
   "level": "soft",
   "check": "semi",
   "how": "发布记录检查这三项；演练过一次回滚",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-032"
  },
  {
   "id": "MOB-033",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "不使用 App Center CodePush 托管服务",
   "level": "hard",
   "check": "auto",
   "how": "依赖扫描 `react-native-code-push` 及指向 App Center 的配置",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-033"
  },
  {
   "id": "MOB-034",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "服务端删除接口或改变语义前，按仍在使用的客户端版本做兼容计划，并预留强制升级通道",
   "level": "hard",
   "check": "semi",
   "how": "CI 用 OpenAPI 比对工具发现破坏性变化，要求 PR 附兼容计划；客户端有最低版本检查",
   "applies": [
    "platform:mobile",
    "kind:service"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-034"
  },
  {
   "id": "MOB-035",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "要求完全断网构建时，在隔离环境里从干净状态完成依赖恢复、编译、签名和安装验证",
   "level": "hard",
   "check": "auto",
   "how": "在无公网的 CI runner 上执行完整构建并安装到测试设备",
   "applies": [
    "platform:mobile",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-035"
  },
  {
   "id": "MOB-036",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "发布前在真机上用 Release 构建跑关键流程 E2E，包括断网、弱网、应用被杀后恢复和旧数据升级",
   "level": "hard",
   "check": "semi",
   "how": "发布检查表要求附真机测试报告（机型、系统版本、构建号）",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-036"
  },
  {
   "id": "MOB-037",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "E2E 用可访问性标识或测试 ID 定位元素，不靠屏幕坐标和截图比对代替断言",
   "level": "soft",
   "check": "semi",
   "how": "评审 E2E 脚本：查找坐标点击和只截图不断言的步骤",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-037"
  },
  {
   "id": "MOB-038",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "核心流程在系统最大字体和读屏（TalkBack、VoiceOver）下真机验证",
   "level": "soft",
   "check": "manual",
   "how": "发布前的无障碍检查记录",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-038"
  },
  {
   "id": "MOB-039",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "系统权限在用到对应功能时再请求，请求前说明用途；被拒绝后功能降级，不退出、不反复弹窗",
   "level": "hard",
   "check": "manual",
   "how": "评审首次启动流程和每个权限的请求时机；E2E 覆盖拒绝权限后的路径",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-039"
  },
  {
   "id": "MOB-040",
   "topic": "28",
   "topicTitle": "移动端总体策略",
   "title": "只申请功能必需的权限；用户同意隐私政策前，不初始化会采集数据的第三方 SDK",
   "level": "hard",
   "check": "semi",
   "how": "构建产物的权限清单与需求比对；启动阶段抓包，检查同意前没有第三方上报",
   "applies": [
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/mobile/#mob-040"
  },
  {
   "id": "AND-001",
   "topic": "29",
   "topicTitle": "Android",
   "title": "新代码用 Kotlin，新界面用 Jetpack Compose；存量 Java 和 View 代码渐进迁移，不一次性重写",
   "level": "soft",
   "check": "semi",
   "how": "CI 统计新增的 `.java` 文件和新增的 XML 布局，出现时要求在 PR 里说明原因",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-001"
  },
  {
   "id": "AND-002",
   "topic": "29",
   "topicTitle": "Android",
   "title": "界面层不直接调用网络或数据库，通过 ViewModel 和数据层（Repository）访问",
   "level": "soft",
   "check": "semi",
   "how": "用模块依赖规则（如 UI 模块不依赖网络或 DAO 模块）或 ArchUnit 类检查找出界面层对 Retrofit、OkHttp、DAO 的直接引用，评审确认",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-002"
  },
  {
   "id": "AND-003",
   "topic": "29",
   "topicTitle": "Android",
   "title": "屏幕状态由 ViewModel 以不可变的 StateFlow 暴露；Compose 中用 collectAsStateWithLifecycle 收集",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查 ViewModel 是否对外暴露 MutableStateFlow；文本扫描 Composable 里的 `collectAsState()` 调用，评审确认",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-003"
  },
  {
   "id": "AND-004",
   "topic": "29",
   "topicTitle": "Android",
   "title": "应用代码不使用 GlobalScope；捕获异常时不吞掉 CancellationException",
   "level": "hard",
   "check": "auto",
   "how": "detekt 的协程规则（GlobalCoroutineUsage、SuspendFunSwallowedCancellation 等）或自定义 Lint 在 CI 阻断；测试代码可按目录豁免",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-004"
  },
  {
   "id": "AND-005",
   "topic": "29",
   "topicTitle": "Android",
   "title": "依赖注入框架和第三方库按需引入；小项目手工注入，引入 Hilt 或 Koin 时写明收益",
   "level": "soft",
   "check": "manual",
   "how": "评审时看新增依赖的 PR 是否写了用途和理由",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-005"
  },
  {
   "id": "AND-006",
   "topic": "29",
   "topicTitle": "Android",
   "title": "不靠锁定屏幕方向或禁止调整窗口大小来规避大屏适配；在平板、折叠屏和分屏下验证核心界面",
   "level": "hard",
   "check": "semi",
   "how": "CI 检查 Manifest 里的 `screenOrientation`、`resizeableActivity` 等限制；发布前在大屏模拟器和分屏模式下跑核心流程，记录结果",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-006"
  },
  {
   "id": "AND-007",
   "topic": "29",
   "topicTitle": "Android",
   "title": "Android 工程只通过 Gradle Wrapper 构建，用 Kotlin DSL 和版本目录（libs.versions.toml），禁止动态版本和未批准的快照版",
   "level": "hard",
   "check": "auto",
   "how": "CI 跑 wrapper 校验，设置 `failOnDynamicVersions()` 和 `failOnChangingVersions()`；做法同 14 的 Wrapper 与依赖锁定规则",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-007"
  },
  {
   "id": "AND-008",
   "topic": "29",
   "topicTitle": "Android",
   "title": "AGP、Gradle、Kotlin、KSP、Compose 编译插件成组升级；组合必须在官方兼容表内，升级 PR 附兼容表链接和 CI 结果",
   "level": "hard",
   "check": "semi",
   "how": "升级 PR 模板固定「兼容表链接」「CI 结果」两栏；CI 对比 AGP 要求的最低 Gradle 版本和实际 Wrapper 版本",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-008"
  },
  {
   "id": "AND-009",
   "topic": "29",
   "topicTitle": "Android",
   "title": "AGP 9 及以后的工程使用内置 Kotlin 支持，不再应用 org.jetbrains.kotlin.android 插件；新代码的注解处理用 KSP，不新增 kapt",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查各模块 `plugins` 块和版本目录里没有 `org.jetbrains.kotlin.android`、`kotlin-kapt`（迁移中的模块按清单豁免）",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-009"
  },
  {
   "id": "AND-010",
   "topic": "29",
   "topicTitle": "Android",
   "title": "Compose 工程通过 org.jetbrains.kotlin.plugin.compose 插件启用 Compose 编译器，其版本跟随 Kotlin；Compose 库用 BOM 管版本",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查 Compose 模块应用了该插件、没有残留 `kotlinCompilerExtensionVersion`；Compose 库依赖不单独写版本号（BOM 覆盖的除外要写理由）",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-010"
  },
  {
   "id": "AND-011",
   "topic": "29",
   "topicTitle": "Android",
   "title": "各构建变体（debug、staging、release）的包名后缀和后端地址分开配置，非 release 变体不得指向生产环境",
   "level": "soft",
   "check": "auto",
   "how": "CI 读取各变体生成的 BuildConfig 或资源配置，比对后端地址白名单",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-011"
  },
  {
   "id": "AND-012",
   "topic": "29",
   "topicTitle": "Android",
   "title": "targetSdk 满足 Google Play 当前的目标 API 要求，在截止日前完成升级；其他分发渠道有更高要求时按该渠道执行",
   "level": "hard",
   "check": "auto",
   "how": "仓库里维护一份「渠道 → 最低 targetSdk → 截止日」配置，CI 断言各变体的 targetSdk 不低于它；每次复核时按官方页面更新这份配置",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-012"
  },
  {
   "id": "AND-013",
   "topic": "29",
   "topicTitle": "Android",
   "title": "minSdk 按目标用户的实际设备清单决定，并在决策记录里写明依据",
   "level": "soft",
   "check": "manual",
   "how": "评审 minSdk 变更时检查是否附设备清单或用户系统版本统计",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-013"
  },
  {
   "id": "AND-014",
   "topic": "29",
   "topicTitle": "Android",
   "title": "包含原生库（含第三方 SDK 带入的 .so）的应用必须支持 16KB 内存页面大小，并在 16KB 环境里测试",
   "level": "hard",
   "check": "auto",
   "how": "CI 对 Release 包跑 `zipalign -c -P 16 -v 4` 和官方 `check_elf_alignment.sh`；Lint 会提示未对齐的原生库；发布前在 16KB 模拟器镜像上跑核心流程",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-014"
  },
  {
   "id": "AND-015",
   "topic": "29",
   "topicTitle": "Android",
   "title": "合并后的 Manifest 权限按白名单管理；新增权限（包括 SDK 带进来的）必须在 PR 里说明用途",
   "level": "hard",
   "check": "auto",
   "how": "CI 从 Release 变体的合并后 Manifest 提取 `uses-permission`，与仓库里的白名单比对，多出的阻断；不需要的权限用 `tools:node=\"remove\"` 去掉",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-015"
  },
  {
   "id": "AND-016",
   "topic": "29",
   "topicTitle": "Android",
   "title": "运行时权限在用户触发对应功能时请求，被拒后功能降级；选图片和视频用系统照片选择器，不为此申请读取全部媒体的权限",
   "level": "soft",
   "check": "semi",
   "how": "测试里覆盖「拒绝」和「永久拒绝」路径；CI 发现 `READ_MEDIA_IMAGES`、`READ_MEDIA_VIDEO`、`READ_EXTERNAL_STORAGE` 时要求说明为什么照片选择器不够用",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-016"
  },
  {
   "id": "AND-017",
   "topic": "29",
   "topicTitle": "Android",
   "title": "每个带 intent-filter 的组件都显式声明 android:exported；不需要被其他应用调用的组件设为 false",
   "level": "hard",
   "check": "auto",
   "how": "Android Lint 在 CI 阻断；CI 从合并后 Manifest 列出所有 `exported=\"true\"` 的组件，与白名单比对",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-017"
  },
  {
   "id": "AND-018",
   "topic": "29",
   "topicTitle": "Android",
   "title": "深链和导出组件收到的参数一律当不可信输入；涉及账户或支付的操作在入口重新鉴权，不凭深链参数直接执行",
   "level": "hard",
   "check": "semi",
   "how": "评审深链处理代码；安全测试里构造恶意深链和 Intent（如用 adb 发送）验证不会绕过登录或直接执行敏感操作",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-018"
  },
  {
   "id": "AND-019",
   "topic": "29",
   "topicTitle": "Android",
   "title": "WebView 加载不可信或第三方内容时，不暴露 addJavascriptInterface 桥接对象，不开启文件和内容访问",
   "level": "hard",
   "check": "semi",
   "how": "Lint 和文本扫描找出 `addJavascriptInterface`、`setAllowFileAccess(true)`、`setAllowUniversalAccessFromFileURLs` 等调用，评审确认加载来源只限自己的域名",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-019"
  },
  {
   "id": "AND-020",
   "topic": "29",
   "topicTitle": "Android",
   "title": "会采集数据的第三方 SDK 关闭自动初始化和自动采集，在用户同意后再显式初始化；每次新增或升级这类 SDK 后做首启抓包对比",
   "level": "hard",
   "check": "semi",
   "how": "检查 Manifest 里 SDK 的自动启动 ContentProvider 是否移除、自动采集开关是否关闭；在全新安装、拒绝同意、同意后三种状态下抓包，对比请求的域名",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-020"
  },
  {
   "id": "AND-021",
   "topic": "29",
   "topicTitle": "Android",
   "title": "在 Google Play 发布的应用，Data safety 表单与实际的数据收集和共享一致（包括第三方 SDK）；SDK 或数据用途变化时同步更新",
   "level": "hard",
   "check": "manual",
   "how": "发布清单里加「Data safety 已复核」一项，对照 SDK 清单和抓包结果签字",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-021"
  },
  {
   "id": "AND-022",
   "topic": "29",
   "topicTitle": "Android",
   "title": "轻量配置用 DataStore，关系数据用 Room；Room 开启 exportSchema，schema 每次变化都有迁移和迁移测试",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查 schema 导出文件已入库且随版本递增；用 MigrationTestHelper 跑从每个已发布 schema 版本升级到当前版本的测试",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-022"
  },
  {
   "id": "AND-023",
   "topic": "29",
   "topicTitle": "Android",
   "title": "本地加密用 Android Keystore 管理密钥；不在新代码里使用已弃用的 Jetpack Security（EncryptedSharedPreferences、EncryptedFile）",
   "level": "hard",
   "check": "auto",
   "how": "CI 扫描依赖里的 `androidx.security:security-crypto` 和对应 API 调用，新增的阻断；存量用法登记迁移计划",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-023"
  },
  {
   "id": "AND-024",
   "topic": "29",
   "topicTitle": "Android",
   "title": "配置备份规则（dataExtractionRules / fullBackupContent），把令牌、设备绑定的数据和用 Keystore 密钥加密的数据排除在云备份和设备迁移之外",
   "level": "hard",
   "check": "semi",
   "how": "CI 检查 Manifest 引用了备份规则文件；评审规则是否覆盖存放凭据的文件和数据库；在新设备上做一次恢复测试",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-024"
  },
  {
   "id": "AND-025",
   "topic": "29",
   "topicTitle": "Android",
   "title": "Release 构建不输出调试日志；令牌、个人信息不写进 Logcat，也不写到外部共享存储",
   "level": "hard",
   "check": "semi",
   "how": "R8 规则移除 `Log.d`/`Log.v` 调用或用只在 debug 生效的日志门面；文本扫描日志调用里的令牌、手机号等字段名，评审确认",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-025"
  },
  {
   "id": "AND-026",
   "topic": "29",
   "topicTitle": "Android",
   "title": "生产构建禁止明文 HTTP；私有 CA 用 network security config 按域名配置信任锚，不写信任所有证书或跳过主机名校验的代码",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查 network security config 中 release 的 `cleartextTrafficPermitted` 不为 true；Android Lint 的 TrustAllX509TrustManager、BadHostnameVerifier 等检查阻断",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-026"
  },
  {
   "id": "AND-027",
   "topic": "29",
   "topicTitle": "Android",
   "title": "一个应用只用一套 HTTP 客户端，统一设置超时和重试；HTTP 日志拦截器只在 debug 构建启用",
   "level": "soft",
   "check": "semi",
   "how": "依赖分析找出同时引入 OkHttp/Retrofit 和 Ktor 的模块；检查日志拦截器只出现在 debug 依赖配置里",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-027"
  },
  {
   "id": "AND-028",
   "topic": "29",
   "topicTitle": "Android",
   "title": "Release 构建开启 R8 代码压缩和资源压缩；keep 规则只保留反射和序列化真正用到的类，不用 -keep class ** 这类宽泛规则",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查 release 变体的 `isMinifyEnabled` 和 `isShrinkResources` 为 true；对 keep 规则文件做差异检查，出现通配整包或全部类的规则时要求说明",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-028"
  },
  {
   "id": "AND-029",
   "topic": "29",
   "topicTitle": "Android",
   "title": "每个发布版本归档 R8 的 mapping 文件，并上传到 Play Console 和崩溃上报平台",
   "level": "hard",
   "check": "auto",
   "how": "发布流水线把 mapping 文件作为制品保存，并校验上传步骤成功",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-029"
  },
  {
   "id": "AND-030",
   "topic": "29",
   "topicTitle": "Android",
   "title": "在 Google Play 发布用 Play App Signing，本地和 CI 只保管上传密钥；签名密钥文件和密码不进仓库",
   "level": "hard",
   "check": "auto",
   "how": "密钥扫描检查仓库里没有 `.jks`、`.keystore` 和签名密码；CI 从密钥管理服务读取上传密钥；发布前比对证书指纹",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-030"
  },
  {
   "id": "AND-031",
   "topic": "29",
   "topicTitle": "Android",
   "title": "同一包名在 Google Play、国内应用市场和官网直装等渠道使用的签名证书，在首次发布前确定，之后不随意更换",
   "level": "hard",
   "check": "manual",
   "how": "发布清单记录每个渠道的签名证书指纹；每次发布比对指纹与记录一致",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-031"
  },
  {
   "id": "AND-032",
   "topic": "29",
   "topicTitle": "Android",
   "title": "应用要安装到 Google 认证的 Android 设备上（包括 Play 以外的渠道）时，在要求生效前完成 Android 开发者验证的身份验证和应用注册",
   "level": "hard",
   "check": "manual",
   "how": "发布清单里记录开发者账号的验证状态和已注册的包名；新增包名或签名时同步注册",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-032"
  },
  {
   "id": "AND-033",
   "topic": "29",
   "topicTitle": "Android",
   "title": "Android Lint 在 CI 运行，存量问题用 baseline 文件冻结，新问题阻断合并",
   "level": "hard",
   "check": "auto",
   "how": "CI 跑 `lint` 任务并设 `abortOnError`；baseline 文件的变化必须有人评审",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-033"
  },
  {
   "id": "AND-034",
   "topic": "29",
   "topicTitle": "Android",
   "title": "ViewModel 和数据层有本地单元测试，覆盖成功、失败、取消和无网络；协程用 kotlinx-coroutines-test 的测试调度器，依赖用 fake",
   "level": "soft",
   "check": "semi",
   "how": "CI 跑本地单元测试任务；评审新的 Repository 和 ViewModel 是否带测试",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-034"
  },
  {
   "id": "AND-035",
   "topic": "29",
   "topicTitle": "Android",
   "title": "关键界面有 Compose UI 测试，按语义或 testTag 查找元素并断言行为；截图测试只作补充，不代替行为断言",
   "level": "soft",
   "check": "semi",
   "how": "CI 在模拟器或 Robolectric 上跑 Compose UI 测试；评审截图测试是否有对应的行为测试",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-035"
  },
  {
   "id": "AND-036",
   "topic": "29",
   "topicTitle": "Android",
   "title": "发布前在开启 R8 的 Release 构建上跑启动和主流程冒烟，不能只测 debug 包",
   "level": "hard",
   "check": "auto",
   "how": "发布流水线安装 release 变体（或 benchmark 等开启 R8 的变体）到模拟器或真机，跑冒烟用例，失败阻断发布",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-036"
  },
  {
   "id": "AND-037",
   "topic": "29",
   "topicTitle": "Android",
   "title": "面向用户的应用生成并随包发布 Baseline Profile，用 Macrobenchmark 衡量启动和关键路径；性能结论以 Release 构建上的实测为准",
   "level": "soft",
   "check": "semi",
   "how": "CI 检查 baseline-prof.txt 存在且随版本更新；定期在固定设备上跑 Macrobenchmark，记录趋势",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-037"
  },
  {
   "id": "AND-038",
   "topic": "29",
   "topicTitle": "Android",
   "title": "可延后的后台工作用 WorkManager；前台服务只用于用户能感知的持续任务，并在 Manifest 中声明对应的 foregroundServiceType",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查每个前台服务都声明了类型和对应权限；评审新增前台服务的用途",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-038"
  },
  {
   "id": "AND-039",
   "topic": "29",
   "topicTitle": "Android",
   "title": "发布到国内应用市场时，为每个市场单独维护上架清单（主体资质、备案信息、隐私政策、权限说明、SDK 清单、签名），以该市场当期规则为准",
   "level": "soft",
   "check": "manual",
   "how": "发布前逐项核对清单并附证据（备案号、隐私政策链接、抓包记录、签名指纹）",
   "applies": [
    "platform:android"
   ],
   "url": "https://plumbrule.dev/topics/android/#and-039"
  },
  {
   "id": "IOS-001",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "新 target 使用 Swift 6 语言模式；仍停在 Swift 5 模式的老 target 要有迁移计划和截止日期",
   "level": "hard",
   "check": "auto",
   "how": "用 `xcodebuild -showBuildSettings` 检查每个 target 的 `SWIFT_VERSION`；例外清单里登记 target、原因和截止日期",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-001"
  },
  {
   "id": "IOS-002",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "每个 target 显式设置默认 actor 隔离和「易用并发」相关的构建设置，不依赖 Xcode 模板的默认值",
   "level": "soft",
   "check": "auto",
   "how": "把各 target 的 `SWIFT_DEFAULT_ACTOR_ISOLATION`、`SWIFT_APPROACHABLE_CONCURRENCY` 导出成快照，CI 比对变化，变化时要求评审确认",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-002"
  },
  {
   "id": "IOS-003",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "更新界面状态的代码放在主 actor（`@MainActor`）；跨任务共享的可变状态用 actor 或不可变值类型保护",
   "level": "hard",
   "check": "semi",
   "how": "在 Swift 6 语言模式下编译不报并发错误；评审时检查共享可变状态的保护方式，必要时用 Thread Sanitizer 跑测试",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-003"
  },
  {
   "id": "IOS-004",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "不用 `@unchecked Sendable`、`nonisolated(unsafe)` 压制并发错误；确需使用时就地注释理由，并登记到例外清单",
   "level": "hard",
   "check": "auto",
   "how": "SwiftLint 自定义规则或 CI 里的 grep 扫描这些关键字，与例外清单比对，不在清单里就失败",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-004"
  },
  {
   "id": "IOS-005",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "生产代码不用 `try!` 和强制解包（`!`）；测试代码和确定不会失败的字面量除外",
   "level": "soft",
   "check": "auto",
   "how": "在 SwiftLint 打开 `force_try`、`force_unwrapping` 规则，测试 target 单独放宽",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-005"
  },
  {
   "id": "IOS-006",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "上传到 App Store Connect 的构建使用满足 App Store 当前要求的 Xcode 和 SDK；CI 固定 Xcode 版本，不用 beta 版构建发布包",
   "level": "hard",
   "check": "auto",
   "how": "CI 开头打印并断言 `xcodebuild -version`；发布流水线拒绝 beta 版 Xcode；每次 Apple 更新上传要求时同步更新 CI 镜像",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-006"
  },
  {
   "id": "IOS-007",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "最低部署版本按目标用户的设备数据决定，写进决策记录，并列出因此不能用或要降级的系统 API",
   "level": "soft",
   "check": "semi",
   "how": "CI 比对各 target 的 `IPHONEOS_DEPLOYMENT_TARGET` 与决策记录里的值；评审时确认决策记录有设备数据依据",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-007"
  },
  {
   "id": "IOS-008",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "新界面默认用 SwiftUI；富文本编辑、相机与视频、复杂列表性能等 SwiftUI 不擅长的场景用 UIKit，并通过 `UIViewRepresentable` / `UIViewControllerRepresentable` 互操作",
   "level": "soft",
   "check": "manual",
   "how": "评审新界面时确认使用 UIKit 的部分写明了原因",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-008"
  },
  {
   "id": "IOS-009",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "最低部署版本 iOS 17 及以后的新代码用 Observation（`@Observable`）管理状态；与 `ObservableObject` 并存时在模块边界隔开，不在同一层混用",
   "level": "soft",
   "check": "semi",
   "how": "扫描同一模块里同时出现 `@Observable` 与 `ObservableObject` 的情况，提示评审确认",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-009"
  },
  {
   "id": "IOS-010",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "`View.body` 里不发网络请求、不读写数据库、不创建重量级对象；副作用放到 `.task` 修饰符或模型层",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查 `body` 内是否调用了网络、数据库、文件接口；可用 SwiftLint 自定义规则找候选",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-010"
  },
  {
   "id": "IOS-011",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "架构从「功能模块 + 观察模型 + Repository + 协议注入」起步；引入 The Composable Architecture（TCA）等状态框架要写决策记录证明必要",
   "level": "soft",
   "check": "manual",
   "how": "评审新增的架构类依赖时查看对应的决策记录",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-011"
  },
  {
   "id": "IOS-012",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "依赖用 Swift Package Manager 管理；新项目不引入 CocoaPods 和 Carthage，只有不提供 SPM 的厂商 SDK 才用 XCFramework 接入",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查仓库里没有新增的 `Podfile`、`Cartfile`",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-012"
  },
  {
   "id": "IOS-013",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "App 仓库把 `Package.resolved` 提交入库；CI 只按锁文件解析依赖，不自动升级",
   "level": "hard",
   "check": "auto",
   "how": "CI 检查 `Package.resolved` 存在；构建时加 `-disableAutomaticPackageResolution`（或 `-onlyUsePackageVersionsFromResolvedFile`），锁文件与解析结果不一致就失败",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-013"
  },
  {
   "id": "IOS-014",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "新增第三方运行时依赖前写明用途、许可证、维护状况、传递依赖和换掉它的办法；系统框架能满足时不引入",
   "level": "soft",
   "check": "semi",
   "how": "CI 发现 `Package.resolved` 新增包时，要求 PR 描述里有这些信息",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-014"
  },
  {
   "id": "IOS-015",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "用 swift-format 管格式、SwiftLint 管质量规则；关掉 SwiftLint 里与格式重叠的规则，CI 用严格模式运行二者",
   "level": "soft",
   "check": "auto",
   "how": "CI 运行 `swift-format lint --strict --recursive <源码目录>` 和 `swiftlint lint --strict`；配置文件 `.swift-format`、`.swiftlint.yml` 入库",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-015"
  },
  {
   "id": "IOS-016",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "网络请求默认用 URLSession + async/await + Codable；设置超时、响应任务取消，按状态码分类错误，日志里不打令牌和个人信息",
   "level": "soft",
   "check": "semi",
   "how": "网络层单元测试用 `URLProtocol` 桩覆盖超时、取消、4xx、5xx、解析失败；评审日志语句",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-016"
  },
  {
   "id": "IOS-017",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "不在 Info.plist 里全局关闭 App Transport Security（`NSAllowsArbitraryLoads`）；必须用明文 HTTP 的，在 `NSExceptionDomains` 里逐个限定域名并写明理由",
   "level": "hard",
   "check": "auto",
   "how": "CI 扫描所有 target 的 Info.plist，出现 `NSAllowsArbitraryLoads = true` 即失败；`NSExceptionDomains` 的每一项要在例外清单里",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-017"
  },
  {
   "id": "IOS-018",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "访问令牌、刷新令牌、密码、私钥只存 Keychain，并选择合适的可访问性等级；不存 UserDefaults、plist 或普通文件",
   "level": "hard",
   "check": "semi",
   "how": "扫描代码里写入 UserDefaults、`@AppStorage` 的键名是否含 token、password、secret 等字样；评审 Keychain 封装的可访问性设置",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-018"
  },
  {
   "id": "IOS-019",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "存放敏感业务数据的文件设置数据保护等级（如 `FileProtectionType.complete`），退出登录时删除",
   "level": "soft",
   "check": "semi",
   "how": "评审写文件的代码是否指定了保护等级；真机测试锁屏后读不到、退出登录后文件被清除",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-019"
  },
  {
   "id": "IOS-020",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "App 和每个扩展都包含隐私清单（`PrivacyInfo.xcprivacy`），按实际使用声明收集的数据类型和必需原因 API 的理由，不复制模板",
   "level": "hard",
   "check": "semi",
   "how": "归档后用 Xcode 的「Generate Privacy Report」汇总所有隐私清单；扫描代码里的必需原因 API（如 UserDefaults、文件时间戳、系统启动时间、磁盘空间），确认每类都有对应的理由代码",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-020"
  },
  {
   "id": "IOS-021",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "引入 Apple 列名的常用第三方 SDK 时，确认它自带隐私清单；以二进制形式引入的还要确认带签名",
   "level": "hard",
   "check": "semi",
   "how": "新增或升级依赖时对照 Apple 的第三方 SDK 名单检查；Xcode 会校验新版本 SDK 与旧版本签名者一致",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-021"
  },
  {
   "id": "IOS-022",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "App Store 的 App 隐私详情与隐私清单、第三方 SDK 的实际采集保持一致；增删或升级 SDK 后重新核对",
   "level": "hard",
   "check": "manual",
   "how": "发布前对照 Privacy Report、SDK 清单和 App Store Connect 里的隐私详情，逐项签字确认",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-022"
  },
  {
   "id": "IOS-023",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "只有在做 Apple 定义的「跟踪」（跨其他公司的 App 或网站关联用户数据）时才用 App 跟踪透明度（ATT）请求授权；用户拒绝后不用其他标识继续跟踪",
   "level": "hard",
   "check": "manual",
   "how": "评审时对照 SDK 清单判断是否构成跟踪；测试拒绝授权后广告标识为全零、没有用指纹等方式替代",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-023"
  },
  {
   "id": "IOS-024",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "Info.plist 里每个权限用途文案（`NS…UsageDescription`）写清 App 用它做什么，并本地化；在用户用到对应功能时再发起授权请求",
   "level": "hard",
   "check": "semi",
   "how": "CI 检查代码用到的权限都有对应的用途键、文案非空且不是占位语；评审授权请求的触发时机",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-024"
  },
  {
   "id": "IOS-025",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "支持创建账号的 App 在 App 内提供删除账号的入口",
   "level": "hard",
   "check": "manual",
   "how": "发布检查清单里确认删除入口可达、能真正删除账号及关联数据",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-025"
  },
  {
   "id": "IOS-026",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "后台工作用 BGTaskScheduler，设置过期处理（`expirationHandler`）、持久化进度、能中断后重入；不把后台任务当定时器",
   "level": "hard",
   "check": "semi",
   "how": "代码检查每个任务都设置了 `expirationHandler` 并调用 `setTaskCompleted`；真机上用调试命令触发任务和过期，验证中断后能恢复",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-026"
  },
  {
   "id": "IOS-027",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "推送的设备令牌每次启动都重新上报服务端，并标明开发或生产环境；推送只用来提醒，业务数据以服务端为准",
   "level": "soft",
   "check": "semi",
   "how": "测试令牌变化、切换环境、丢失推送三种情况，确认服务端映射正确、打开 App 后能补齐数据",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-027"
  },
  {
   "id": "IOS-028",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "APNs 认证密钥（`.p8`）只放在服务端的密钥库，不打进 App、不进仓库",
   "level": "hard",
   "check": "auto",
   "how": "密钥扫描规则匹配 `.p8` 文件和 `-----BEGIN PRIVATE KEY-----`；App 包内容扫描",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-028"
  },
  {
   "id": "IOS-029",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "通知服务扩展只做轻量处理（如下载附件、解密正文），在系统时限内完成；时限将到时回退显示原始内容",
   "level": "soft",
   "check": "semi",
   "how": "检查实现了 `serviceExtensionTimeWillExpire`；在慢网和断网下测试通知仍能显示",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-029"
  },
  {
   "id": "IOS-030",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "小组件按时间线和刷新预算设计，界面能接受延迟刷新；不做秒级实时看板",
   "level": "soft",
   "check": "manual",
   "how": "评审小组件的刷新策略；锁屏可见的小组件确认不显示敏感数据",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-030"
  },
  {
   "id": "IOS-031",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "每个 target 和扩展的 entitlements 只开必需的能力；App Group 和 Keychain 共享组只共享必需的数据",
   "level": "soft",
   "check": "semi",
   "how": "CI 把各 target 的 `.entitlements` 导出比对，变化时要求评审说明",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-031"
  },
  {
   "id": "IOS-032",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "新的单元和集成测试用 Swift Testing；UI 自动化和性能测试用 XCTest（XCUITest）；存量 XCTest 用例不强制重写",
   "level": "soft",
   "check": "semi",
   "how": "评审新增测试文件使用的框架；两套框架可在同一个测试 target 里共存",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-032"
  },
  {
   "id": "IOS-033",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "UI 测试用 `accessibilityIdentifier` 定位元素，并在关键页面调用 `performAccessibilityAudit()` 做自动无障碍审计",
   "level": "soft",
   "check": "auto",
   "how": "UI 测试里不出现坐标点击；关键页面的测试包含无障碍审计调用，CI 上审计失败即测试失败",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-033"
  },
  {
   "id": "IOS-034",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "CI 保存测试结果包（`.xcresult`），失败时能看到截图和日志",
   "level": "soft",
   "check": "auto",
   "how": "`xcodebuild test` 加 `-resultBundlePath`，CI 把结果包作为构建产物上传",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-034"
  },
  {
   "id": "IOS-035",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "快照测试只覆盖关键页面，固定模拟器机型、系统版本、语言、字号和深浅色；基线变化必须人工审阅，CI 不自动接受",
   "level": "soft",
   "check": "semi",
   "how": "快照配置写死运行环境；基线图片的改动走 PR 评审",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-035"
  },
  {
   "id": "IOS-036",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "控件提供 VoiceOver 能读懂的 `accessibilityLabel`，必要时补 `accessibilityValue`、`accessibilityHint`；只有图标的按钮必须有标签，自定义控件声明正确的 traits",
   "level": "hard",
   "check": "semi",
   "how": "XCUITest 的无障碍审计找候选；VoiceOver 真机走一遍核心流程",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-036"
  },
  {
   "id": "IOS-037",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "文字使用动态字体的文本样式（SwiftUI 的 `.font(.body)` 等，UIKit 的 `preferredFont(forTextStyle:)` 加 `adjustsFontForContentSizeCategory`），最大辅助字号下核心内容不截断",
   "level": "hard",
   "check": "semi",
   "how": "用最大辅助字号截图核心页面并人工检查；无障碍审计会报告被截断的文字",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-037"
  },
  {
   "id": "IOS-038",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "颜色用系统语义色或资产目录里带深浅色变体的颜色；动画遵守「减弱动态效果」设置；支持 iPad 时适配尺寸变化和分屏",
   "level": "soft",
   "check": "semi",
   "how": "用深色、浅色、增强对比度、减弱动态效果、iPad 分屏组合截图检查",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-038"
  },
  {
   "id": "IOS-039",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "界面文案放进 String Catalog（`.xcstrings`）；日期、数字、货币用 `FormatStyle` 按区域格式化，复数用 String Catalog 的复数变体，不拼接句子",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查 String Catalog 里没有「需要审阅」「未翻译」状态的条目；伪本地化跑一遍 UI 测试",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-039"
  },
  {
   "id": "IOS-040",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "签名证书私钥、描述文件、App Store Connect API 密钥放在密钥库或 CI 机密里，按环境隔离，不进仓库",
   "level": "hard",
   "check": "auto",
   "how": "密钥扫描匹配 `.p12`、`.mobileprovision`、`.p8` 和私钥头；CI 签名凭据只在发布流水线可见",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-040"
  },
  {
   "id": "IOS-041",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "每个发布构建保留 `.xcarchive`、dSYM 符号文件、对应提交编号、Xcode 版本和 `Package.resolved`",
   "level": "soft",
   "check": "auto",
   "how": "发布流水线把这些产物归档，缺一项就失败；dSYM 同时上传到崩溃上报服务",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-041"
  },
  {
   "id": "IOS-042",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "分发渠道在立项时书面决定并符合 Apple 的资格条件；不用 Enterprise 证书给外部客户或公众分发",
   "level": "hard",
   "check": "manual",
   "how": "评审分发决策记录：用户是谁、用哪个渠道、组织是否符合该渠道的资格",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-042"
  },
  {
   "id": "IOS-043",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "在欧盟通过替代应用市场或网页分发前，确认已签署相应条款、App 能通过苹果公证，并评估佣金",
   "level": "soft",
   "check": "manual",
   "how": "分发决策记录里写明适用条款、公证流程和费用测算",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-043"
  },
  {
   "id": "IOS-044",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "提交审核时提供可用的演示账号和审核期间可访问的后端；需要特殊硬件或环境的功能附演示视频",
   "level": "soft",
   "check": "manual",
   "how": "发布检查清单确认演示账号在提交当天能登录、后端对公网可达",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-044"
  },
  {
   "id": "IOS-045",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "在中国大陆 App Store 上架、且需要备案的 App，在 App Store Connect 填写与 App 一致的 APP 备案号",
   "level": "hard",
   "check": "manual",
   "how": "上架前核对备案主体、App 名称、Bundle ID 与 App Store Connect 里填写的备案号一致",
   "applies": [
    "platform:ios"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-045"
  },
  {
   "id": "IOS-046",
   "topic": "30",
   "topicTitle": "iOS",
   "title": "React Native、Flutter 等跨平台项目同样维护 iOS 原生资产：隐私清单（含各插件的清单）、entitlements、权限用途文案、签名配置",
   "level": "soft",
   "check": "semi",
   "how": "归档后用 Privacy Report 检查所有插件的隐私清单都已合并；CI 比对 entitlements 和 Info.plist 变化",
   "applies": [
    "platform:ios",
    "platform:mobile"
   ],
   "url": "https://plumbrule.dev/topics/ios/#ios-046"
  },
  {
   "id": "DSK-001",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "写明支持矩阵：每个目标的操作系统和版本、CPU 架构、窗口系统（Linux）、安装格式和分发渠道；CI 构建矩阵与之一致",
   "level": "soft",
   "check": "semi",
   "how": "仓库里有一份结构化的支持矩阵文件；CI 脚本比对矩阵和构建任务，缺项时报错",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-001"
  },
  {
   "id": "DSK-002",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "框架选型写进决策记录，列出至少一个备选、取舍理由和推翻条件；不在规范层规定「一律用某个框架」",
   "level": "soft",
   "check": "manual",
   "how": "评审决策记录是否包含候选、取舍、推翻条件；有条件时附同场景小样的安装包大小、内存和启动时间对比",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-002"
  },
  {
   "id": "DSK-003",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "使用 Qt 开源版时，按 LGPLv3 履行义务：动态链接、允许用户替换并重新链接 Qt 库、随附许可证文本和源码获取方式，且不使用仅 GPL 授权的模块；做不到就购买商业许可",
   "level": "hard",
   "check": "manual",
   "how": "发布前由负责人核对：链接方式、所用 Qt 模块的许可证、随包的许可证声明和源码获取说明；拿不准的交法务确认",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-003"
  },
  {
   "id": "DSK-004",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "正式发布构建不使用 alpha、beta、nightly 版的框架、打包工具和更新器",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查锁文件里这些依赖的版本号不含预发布标记",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-004"
  },
  {
   "id": "DSK-005",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "Electron 应用使用官方仍在支持的主版本（最新的三个稳定主版本之一），并跟随安全补丁升级",
   "level": "hard",
   "check": "auto",
   "how": "CI 或定时任务比对项目的 Electron 主版本和官方发布时间表，超出支持范围时报错",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-005"
  },
  {
   "id": "DSK-006",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "界面只通过命名的、带类型和参数校验、逐方法授权的窄命令调用系统能力；不向界面暴露通用的执行命令、读写任意文件、结束任意进程的接口",
   "level": "hard",
   "check": "semi",
   "how": "列出桥接层暴露的全部命令并评审；用 lint 或架构检查禁止在桥接层出现通用执行、任意路径、按进程号操作的接口；为每个命令写拒绝用例",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-006"
  },
  {
   "id": "DSK-007",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "Electron 的所有窗口必须保持 contextIsolation、sandbox、webSecurity 打开，nodeIntegration 关闭",
   "level": "hard",
   "check": "auto",
   "how": "lint 或静态扫描检查所有 BrowserWindow / webPreferences 配置；启动时断言这些选项的实际值",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-007"
  },
  {
   "id": "DSK-008",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "Electron 的 preload 只用 contextBridge 暴露命名的具体方法，不把 ipcRenderer、require 或整个模块暴露给页面",
   "level": "hard",
   "check": "auto",
   "how": "静态扫描 preload 文件，发现暴露 ipcRenderer、require 或透传任意通道名的写法时报错",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-008"
  },
  {
   "id": "DSK-009",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "Electron 主进程的每个 IPC 处理函数都校验消息发送方和入参结构",
   "level": "hard",
   "check": "semi",
   "how": "处理函数集中注册，单元测试覆盖「来自非预期来源」和「参数不合法」两类反例",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-009"
  },
  {
   "id": "DSK-010",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "拦截不受控的页面跳转和新窗口；打开外部链接只放行白名单协议，不把用户输入直接交给 shell.openExternal 一类接口",
   "level": "hard",
   "check": "semi",
   "how": "测试覆盖跳转到非预期地址、window.open、`file:` 协议和自定义协议链接等情况；静态扫描 openExternal 的调用点",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-010"
  },
  {
   "id": "DSK-011",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "生产版设置经过评审的内容安全策略（CSP），不加载远程脚本、不使用 eval",
   "level": "hard",
   "check": "auto",
   "how": "静态检查页面和打包配置中的 CSP；启动后的冒烟测试读取实际生效的 CSP",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-011"
  },
  {
   "id": "DSK-012",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "不可信的远程网页、第三方插件内容、HTML 预览不放进有原生桥的窗口；需要展示时用不带桥的隔离视图或交给系统浏览器",
   "level": "hard",
   "check": "manual",
   "how": "评审时列出每个窗口和视图加载的内容来源，核对加载远程内容的视图没有 preload 或 capability",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-012"
  },
  {
   "id": "DSK-013",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "Tauri 的 capability 必须绑定到具体窗口，只授予该窗口用到的权限；不授予通用的命令执行权限",
   "level": "hard",
   "check": "auto",
   "how": "CI 解析 capabilities 目录，检查每个 capability 指定了窗口，且不含禁用清单里的权限（如 shell 插件的通用执行权限）；权限文件变化时要求安全评审",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-013"
  },
  {
   "id": "DSK-014",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "改动 preload、主进程窗口配置、Tauri capability、深链处理、凭据存储、更新器和签名脚本时，必须经安全负责人评审",
   "level": "soft",
   "check": "auto",
   "how": "把这些路径写进 CODEOWNERS，分支保护要求负责人批准",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-014"
  },
  {
   "id": "DSK-015",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "界面和本机后台之间优先用框架自带的进程间通信；确需开本机 HTTP 端口时，遵守 32 的本机服务防护规则",
   "level": "soft",
   "check": "semi",
   "how": "评审时检查是否新开了监听端口；开了的按 RUN-038、RUN-039、RUN-040 逐项核对",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-015"
  },
  {
   "id": "DSK-016",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "令牌和密码只存系统凭据库（macOS 钥匙串、Windows 凭据管理器或 DPAPI、Linux Secret Service），不放 localStorage、明文配置文件或日志",
   "level": "hard",
   "check": "semi",
   "how": "密钥扫描覆盖仓库和构建日志；测试登录后检查应用数据目录和浏览器存储里没有明文令牌",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-016"
  },
  {
   "id": "DSK-017",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "Linux 上没有可用的安全凭据后端时（如 Electron safeStorage 退化为 basic_text），不保存敏感令牌，提示用户配置凭据服务或改为每次登录",
   "level": "hard",
   "check": "auto",
   "how": "启动时读取实际使用的存储后端并在退化时走专门分支；在 GNOME、KDE、无钥匙环三种环境下测试",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-017"
  },
  {
   "id": "DSK-018",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "用户数据写入系统规定的用户数据目录，不写安装目录；有本地数据库的，每次升级用旧版本数据测试迁移",
   "level": "soft",
   "check": "semi",
   "how": "测试在只读安装目录、普通用户权限下运行；发布前用上一版的数据样本跑升级",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-018"
  },
  {
   "id": "DSK-019",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "深链只认固定的协议名，参数按结构严格校验，只映射到预先定义的动作；不执行链接里的命令，不直接打开链接里的文件路径",
   "level": "hard",
   "check": "semi",
   "how": "对深链解析函数做模糊测试；测试应用已在运行时深链能交给已有实例处理（单实例）",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-019"
  },
  {
   "id": "DSK-020",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "托盘、通知、自启动、文件关联用框架官方接口或插件实现；自启动默认关闭，用户和企业策略都能关掉",
   "level": "soft",
   "check": "semi",
   "how": "依赖评审确认使用官方插件；测试设置页开关后读取系统里的实际状态",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-020"
  },
  {
   "id": "DSK-021",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "要求离线可用时，首次启动不依赖开发服务器、CDN、远程字体或远程配置，所有关键资源随包打包",
   "level": "hard",
   "check": "semi",
   "how": "在断网的干净虚拟机里安装并首启，同时记录 DNS 和网络请求；静态扫描打包产物里的外部地址",
   "applies": [
    "platform:desktop",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-021"
  },
  {
   "id": "DSK-022",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "使用系统 WebView 的应用（Tauri、Wails）面向不能联网的 Windows 环境时，安装包内置 WebView2 离线安装包或固定版本运行时，或由 IT 预装并验证",
   "level": "hard",
   "check": "semi",
   "how": "检查打包配置中 WebView2 的安装方式；在断网的 Windows 虚拟机上首装首启",
   "applies": [
    "platform:desktop",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-022"
  },
  {
   "id": "DSK-023",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "要求在内网构建时，提前准备 npm、Cargo、Go 等依赖镜像、框架二进制下载镜像和离线安装物料，并在无外网环境里完整构建一次",
   "level": "soft",
   "check": "semi",
   "how": "在断网的构建机上从干净状态完成依赖恢复、构建、签名和打包",
   "applies": [
    "platform:desktop",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-023"
  },
  {
   "id": "DSK-024",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "在 Mac App Store 之外分发的 macOS 应用和安装包，必须用 Developer ID 证书签名、开启 Hardened Runtime、用 notarytool 公证并附上公证票据",
   "level": "hard",
   "check": "auto",
   "how": "发布任务里运行 `codesign --verify --deep --strict`、`spctl -a -vv`、`xcrun stapler validate`，任一失败即阻断发布",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-024"
  },
  {
   "id": "DSK-025",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "面向公众分发的 Windows 安装包和可执行文件，必须用公共信任的证书做 Authenticode 签名并加时间戳；企业内部用私有证书时，要有 IT 已下发根证书的证明",
   "level": "hard",
   "check": "auto",
   "how": "发布任务用 signtool verify 或 Get-AuthenticodeSignature 检查每个可执行文件和安装包的签名状态与时间戳",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-025"
  },
  {
   "id": "DSK-026",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "签名证书和私钥放在硬件安全模块或云签名服务里，只在受保护的发布任务中使用，不放进仓库、普通文件或开发者电脑",
   "level": "soft",
   "check": "semi",
   "how": "密钥扫描覆盖仓库；评审发布流水线，确认签名步骤只在受保护分支和环境里运行",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-026"
  },
  {
   "id": "DSK-027",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "Linux 的 deb、rpm 通过签名的软件仓库分发；AppImage 等独立文件同时提供校验和与签名，并说明验证方法",
   "level": "soft",
   "check": "semi",
   "how": "发布任务检查仓库元数据签名和校验和文件存在；安装测试从仓库安装",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-027"
  },
  {
   "id": "DSK-028",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "面向企业统一部署的安装包，必须支持静默安装、升级和卸载，退出码可靠",
   "level": "hard",
   "check": "semi",
   "how": "在虚拟机里用静默参数安装、升级、卸载，检查退出码和检测规则",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-028"
  },
  {
   "id": "DSK-029",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "Linux 版在支持的最老发行版上构建，或在每个目标发行版上分别构建；不把一个 AppImage 当作支持所有 Linux",
   "level": "soft",
   "check": "semi",
   "how": "CI 构建环境与支持矩阵里最老的发行版一致；每个目标发行版真机启动一次",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-029"
  },
  {
   "id": "DSK-030",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "Flatpak 应用通过 portal 访问文件和系统能力，不常开主机文件系统等宽泛权限",
   "level": "soft",
   "check": "auto",
   "how": "CI 检查 Flatpak 清单里的 finish-args，出现宽泛文件系统权限时要求写明理由",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-030"
  },
  {
   "id": "DSK-031",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "每个安装渠道只指定一个更新负责方（企业 IT、系统包管理器或应用内更新器）；企业部署的 MSI、MSIX、PKG、deb、rpm 默认由 IT 管控，关闭应用内更新",
   "level": "hard",
   "check": "manual",
   "how": "发布配置里为每个渠道写明更新负责方；评审时核对企业包已关闭应用内更新",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-031"
  },
  {
   "id": "DSK-032",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "自动更新用成熟的更新器，不自己写；更新包必须签名，安装前验证签名、版本和目标架构",
   "level": "hard",
   "check": "semi",
   "how": "评审更新器选型；测试被篡改的更新包、错误架构的包会被拒绝",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-032"
  },
  {
   "id": "DSK-033",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "发布前测试更新的异常情况：下载中断、更新包被篡改、降级、从上一个正式版升级后数据可用",
   "level": "soft",
   "check": "semi",
   "how": "发布测试清单包含这些场景并记录结果",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-033"
  },
  {
   "id": "DSK-034",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "接入崩溃上报，覆盖原生崩溃和主进程、渲染进程的未捕获异常；发布时保存符号文件和 source map，以便还原堆栈",
   "level": "soft",
   "check": "semi",
   "how": "每次发布的产物清单里有符号文件；测试版人为触发一次崩溃，确认能看到还原后的堆栈",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-034"
  },
  {
   "id": "DSK-035",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "未经用户或企业配置同意，不向公网上传崩溃报告和使用数据；上传前脱敏，上报地址可改为客户自建的服务或关闭",
   "level": "hard",
   "check": "semi",
   "how": "在未同意状态下抓包确认没有对外请求；对脱敏函数做单元测试；检查上报地址是配置项",
   "applies": [
    "platform:desktop",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-035"
  },
  {
   "id": "DSK-036",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "每个声明支持的「系统 × CPU 架构 × 安装格式」，发布前都在真机或对应的虚拟机上完成安装、首启、升级和卸载；不以交叉编译成功或 x64 仿真代替",
   "level": "soft",
   "check": "manual",
   "how": "发布记录里按支持矩阵逐项附安装日志或截图",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-036"
  },
  {
   "id": "DSK-037",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "测试报告分开写网页功能、桥接层、系统集成、安装发布四层结果；网页 E2E 通过不得报告为桌面集成通过",
   "level": "hard",
   "check": "semi",
   "how": "测试报告模板固定四层栏目，评审时核对每层的证据",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-037"
  },
  {
   "id": "DSK-038",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "在每个目标系统上验证深色模式、系统缩放、输入法、多显示器、睡眠唤醒后恢复和断网；键盘与读屏按 09 的要求人工测试",
   "level": "soft",
   "check": "manual",
   "how": "真机检查清单逐项记录；Windows 用 Accessibility Insights 辅助检查",
   "applies": [
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-038"
  },
  {
   "id": "DSK-039",
   "topic": "31",
   "topicTitle": "桌面应用",
   "title": "国产操作系统和 CPU（统信、麒麟、龙芯、飞腾、鲲鹏等）只在客户同款系统和 CPU 上真机验证通过后才宣称支持，不从「支持 Linux」推出",
   "level": "soft",
   "check": "manual",
   "how": "支持矩阵里国产平台逐项标注验证状态和验证机型",
   "applies": [
    "platform:desktop",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/desktop/#dsk-039"
  },
  {
   "id": "RUN-001",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "无人值守、跳过逐条审批或处理不可信仓库时，Agent 和它生成的命令必须在容器或虚拟机里运行，不在宿主上直接运行",
   "level": "hard",
   "check": "semi",
   "how": "启动脚本检查自身运行在容器或虚拟机中（如 `systemd-detect-virt`、容器运行时标记），检查不通过就退出；评审时核对启动方式",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-001"
  },
  {
   "id": "RUN-002",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "不把引擎自带的沙箱当唯一防线；按它实际覆盖的范围补外层隔离",
   "level": "hard",
   "check": "manual",
   "how": "评审时对照引擎官方文档，逐项列出沙箱覆盖和不覆盖的工具（shell、文件工具、hooks、MCP、联网工具），不覆盖的部分要有外层隔离或权限设置兜底",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-002"
  },
  {
   "id": "RUN-003",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "沙箱、容器运行时或出口代理不可用时拒绝运行，不自动降级到裸机重试",
   "level": "hard",
   "check": "auto",
   "how": "故障注入测试：停掉容器运行时、关闭 user namespace、停掉代理，断言任务报错退出而不是在宿主上执行；Claude Code 设置 `allowUnsandboxedCommands` 为 false",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-003"
  },
  {
   "id": "RUN-004",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "跳过权限审批的开关（`--dangerously-skip-permissions`、`--yolo`、`danger-full-access`）只能在断网或出口受控的一次性隔离环境里使用",
   "level": "hard",
   "check": "auto",
   "how": "CI 或启动脚本扫描 Agent 启动参数和配置，在非隔离环境出现这些开关时拒绝启动",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-004"
  },
  {
   "id": "RUN-005",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "非交互运行 Codex 需要写文件时，显式指定 `--sandbox workspace-write`，并保持默认关网",
   "level": "soft",
   "check": "auto",
   "how": "扫描启动脚本参数；检查配置里 `network_access` 是否被打开并有理由",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-005"
  },
  {
   "id": "RUN-006",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "处理外部或不可信仓库时，不自动加载仓库里的 Agent 配置（如 `.claude/settings.json`、`.mcp.json`、`.codex/`、hooks）",
   "level": "hard",
   "check": "semi",
   "how": "启动前扫描工作区里的 Agent 配置文件并列出来；Codex 把该项目设为 `trust_level = \"untrusted\"`；Claude Code 不对该目录确认信任",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-006"
  },
  {
   "id": "RUN-007",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "隔离环境不得挂载宿主家目录、`~/.ssh`、SSH agent 套接字、钥匙串或凭据库、云凭据目录、浏览器配置和容器管理套接字（`docker.sock`、Podman 套接字）",
   "level": "hard",
   "check": "auto",
   "how": "启动前用 `podman inspect` / `docker inspect` / 虚拟机配置检查挂载列表，命中禁止清单就拒绝；在宿主放诱饵文件，测试隔离环境内读不到",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-007"
  },
  {
   "id": "RUN-008",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "每个任务用独立工作区，优先把仓库克隆到隔离环境内部；必须共享时只挂用户批准的单个项目目录",
   "level": "soft",
   "check": "semi",
   "how": "启动脚本记录挂载源路径，评审时检查是否只有一个项目目录",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-008"
  },
  {
   "id": "RUN-009",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "挂载和导出前解析真实路径，拒绝 `..`、绝对路径、符号链接、硬链接和 Windows junction 越出工作区",
   "level": "hard",
   "check": "auto",
   "how": "用包含符号链接、硬链接、junction 的测试样例跑导出流程，断言工作区外文件没有变化",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-009"
  },
  {
   "id": "RUN-010",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "Linux 容器必须 rootless 运行、不用 `--privileged`，丢弃全部 capability、开启 no-new-privileges、保留默认 seccomp，并以非 root 用户运行",
   "level": "hard",
   "check": "auto",
   "how": "用 `podman inspect` / `docker inspect` 检查 Privileged、CapDrop、SecurityOpt、User；在容器内探测 capability 和 seccomp 状态",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-010"
  },
  {
   "id": "RUN-011",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "给每个任务设 CPU、内存、进程数、磁盘和运行时长上限",
   "level": "hard",
   "check": "auto",
   "how": "检查容器或 cgroup 的 `pids.max`、`memory.max`、`cpu.max` 和超时设置；跑进程炸弹、内存打满、磁盘写满测试，断言宿主不受影响",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-011"
  },
  {
   "id": "RUN-012",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "bubblewrap、Landlock、seccomp 只作辅助层，不单独当高风险任务的边界",
   "level": "soft",
   "check": "manual",
   "how": "评审隔离方案时确认还有容器或虚拟机、出口代理和资源限制",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-012"
  },
  {
   "id": "RUN-013",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "不同信任域（不同客户、不同组织）的代码不在同一宿主的容器里跑，改用独立虚拟机或独立机器",
   "level": "soft",
   "check": "manual",
   "how": "部署评审时列出每台宿主承载的项目和所属组织",
   "applies": [
    "kind:agent",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-013"
  },
  {
   "id": "RUN-014",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "把 Agent 跑成 Linux 服务时，个人机用 systemd 用户服务，服务器用专用无登录账号的系统服务，并开启服务加固选项",
   "level": "soft",
   "check": "auto",
   "how": "用 `systemd-analyze security <服务名>` 检查评分；确认设置了 `NoNewPrivileges`、`ProtectSystem`、`KillMode=control-group`、`TasksMax`、`MemoryMax`",
   "applies": [
    "kind:agent",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-014"
  },
  {
   "id": "RUN-015",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "macOS 上无人值守运行时，外层用 Linux 虚拟机（如 Lima），不单靠 Seatbelt（`sandbox-exec`）",
   "level": "soft",
   "check": "manual",
   "how": "评审运行方式，确认 Agent 命令跑在虚拟机里",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-015"
  },
  {
   "id": "RUN-016",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "虚拟机和桌面容器工具不得共享整个家目录；检查并关掉默认的宿主目录共享",
   "level": "hard",
   "check": "auto",
   "how": "检查 Lima 实例配置的 `mounts`、Docker Desktop 的文件共享列表、OrbStack 的宿主集成设置，命中家目录或 `~/Library` 就拒绝启动",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-016"
  },
  {
   "id": "RUN-017",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "Windows 上用 Agent 专用的 WSL2 发行版，在 `/etc/wsl.conf` 关闭自动挂载和 Windows 互操作，并实测生效",
   "level": "hard",
   "check": "auto",
   "how": "检查 `[automount] enabled=false`、`[interop] enabled=false`、`appendWindowsPath=false`；在发行版里执行 `ls /mnt/c`、`cmd.exe`、`powershell.exe`，断言都失败",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-017"
  },
  {
   "id": "RUN-018",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "WSL 里的工作区放在 Linux 文件系统（ext4），不放 `/mnt/c`",
   "level": "soft",
   "check": "auto",
   "how": "启动脚本检查工作区路径不以 `/mnt/` 开头",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-018"
  },
  {
   "id": "RUN-019",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "WSL 的 mirrored 网络、DNS 隧道、自动代理只用来改善连通，不当作隔离；出口限制在 WSL 内或代理层另做并实测",
   "level": "soft",
   "check": "semi",
   "how": "在 NAT 和 mirrored 两种模式、开关 VPN 时分别跑出口负向测试",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-019"
  },
  {
   "id": "RUN-020",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "需要原生 Windows 命令时，Claude Code 改在 WSL2 里运行；Codex 优先用 MXC，其次 elevated，不把 unelevated 当网络边界",
   "level": "soft",
   "check": "manual",
   "how": "评审时确认原生 Windows 上的引擎和沙箱模式",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-020"
  },
  {
   "id": "RUN-021",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "Windows Sandbox 只用于人工短时试验，不当常驻运行环境；使用时在配置文件里关掉网络",
   "level": "soft",
   "check": "semi",
   "how": "检查 `.wsb` 配置里的网络项",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-021"
  },
  {
   "id": "RUN-022",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "隔离环境默认断网；需要联网时只能经过独立的出口代理，不只靠 `HTTP_PROXY` 环境变量或域名白名单",
   "level": "hard",
   "check": "auto",
   "how": "在隔离环境内用不走代理的方式（直接 socket、忽略代理变量的工具）连外网，断言连接失败",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-022"
  },
  {
   "id": "RUN-023",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "出口代理必须拦住直连 IP、IPv6、UDP 和自行发起的 DNS、本机回环、局域网、云元数据地址、DNS 重绑定，以及重定向到内网",
   "level": "hard",
   "check": "auto",
   "how": "攻击测试清单中的网络项（见 RUN-033）全部断言被阻止，并保留代理日志",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-023"
  },
  {
   "id": "RUN-024",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "依赖下载和编码分两个阶段：准备阶段只连内网镜像仓库，编码阶段断网；安装脚本同样在隔离环境里运行",
   "level": "soft",
   "check": "semi",
   "how": "检查两个阶段的网络配置；在编码阶段跑联网负向测试",
   "applies": [
    "kind:agent",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-024"
  },
  {
   "id": "RUN-025",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "不为跑通任务关闭 TLS 校验（如 `NODE_TLS_REJECT_UNAUTHORIZED=0`、`curl -k`、关掉 git 的 sslVerify）；企业根证书以只读方式放进隔离环境",
   "level": "hard",
   "check": "auto",
   "how": "静态扫描脚本、配置和 Agent 生成的命令，命中这些写法就拒绝",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-025"
  },
  {
   "id": "RUN-026",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "在企业电脑或机房部署前，先自检代理、私有根证书、虚拟化是否可用和离线镜像；被策略禁止时报告「不支持」，不要求用户关闭杀毒或安全软件",
   "level": "soft",
   "check": "manual",
   "how": "部署清单里有自检步骤和结果记录",
   "applies": [
    "kind:agent",
    "kind:onprem"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-026"
  },
  {
   "id": "RUN-027",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "长期密钥（模型 API 密钥、代码仓库令牌、云凭据）不进 Agent 所在的隔离环境，不放环境变量、命令行参数和文件",
   "level": "hard",
   "check": "auto",
   "how": "在宿主侧放诱饵令牌，测试隔离环境内的 `/proc/*/environ`、进程参数、挂载和日志里都找不到；对日志跑密钥扫描",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-027"
  },
  {
   "id": "RUN-028",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "默认不给 Agent 推送权限；必须推送时用限定仓库和分支的短期令牌，并配合分支保护",
   "level": "hard",
   "check": "semi",
   "how": "检查给 Agent 的令牌权限范围；分支保护禁止直接推送受保护分支",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-028"
  },
  {
   "id": "RUN-029",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "宿主侧的长期凭据放系统凭据库（钥匙串、Windows 凭据管理器或 DPAPI、Secret Service）或机房的密钥服务，不放 `~/.env` 等明文文件",
   "level": "soft",
   "check": "semi",
   "how": "密钥扫描检查家目录和项目目录的明文凭据文件",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-029"
  },
  {
   "id": "RUN-030",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "控制 Agent 能读的文件范围；拿不到密钥，Agent 也能把读到的敏感文件当提示词发给模型",
   "level": "soft",
   "check": "manual",
   "how": "评审时列出隔离环境里可读的目录，确认没有与任务无关的敏感数据",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-030"
  },
  {
   "id": "RUN-031",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "取消或超时时回收整个隔离单元（容器、虚拟机、cgroup 或 Job Object），并确认进程数为零；不能只杀父进程",
   "level": "hard",
   "check": "auto",
   "how": "测试里用 `setsid`、`nohup`、后台子进程、父进程崩溃等方式留下后代进程，取消后断言容器已删除、cgroup 的 `pids.current` 为 0 或 Job Object 已空",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-031"
  },
  {
   "id": "RUN-032",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "任务结束后清理工作区、缓存和临时文件；日志设大小上限和保留期",
   "level": "soft",
   "check": "auto",
   "how": "任务结束后检查工作区目录和磁盘占用回到基线",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-032"
  },
  {
   "id": "RUN-033",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "每种运行方式上线前跑一遍攻击测试清单，并在系统、内核、容器运行时或 Agent 升级后重跑",
   "level": "hard",
   "check": "semi",
   "how": "测试用假密钥、假仓库、内网假服务，断言「被阻止」；记录系统版本、内核、运行时和 Agent 版本、期望与实际结果",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-033"
  },
  {
   "id": "RUN-034",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "Agent 不能修改自己的隔离配置、出口规则、镜像和更新程序；这些文件只读挂载或放在隔离环境之外",
   "level": "hard",
   "check": "auto",
   "how": "测试在隔离环境里写这些文件，断言失败且宿主侧没有变化",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-034"
  },
  {
   "id": "RUN-035",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "固定隔离环境的镜像（按 digest）和 Agent 版本；升级前先过兼容和攻击测试",
   "level": "soft",
   "check": "auto",
   "how": "启动脚本只接受按 digest 指定的镜像；检查 Agent 版本是否在已测试列表里",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-035"
  },
  {
   "id": "RUN-036",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "需要图形界面、GPU、Docker 构建或平台签名的任务用单独的受限配置并人工批准，不抬高默认环境的权限",
   "level": "soft",
   "check": "manual",
   "how": "列出每种特殊配置及其批准记录",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-036"
  },
  {
   "id": "RUN-037",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "在个人电脑上，无人值守运行要用户明确开启，可以随时一键停止，重启或睡眠唤醒后不自动恢复",
   "level": "soft",
   "check": "manual",
   "how": "安装后检查默认状态为关闭；重启后检查没有自动开始执行",
   "applies": [
    "kind:agent"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-037"
  },
  {
   "id": "RUN-038",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "本机 HTTP 服务（Agent 控制接口、本机 MCP 服务、开发服务器）只监听 127.0.0.1 或 ::1，不监听 0.0.0.0",
   "level": "hard",
   "check": "auto",
   "how": "用 `ss -ltnp`（Linux）、`lsof -iTCP -sTCP:LISTEN`（macOS）、`netstat -ano`（Windows）检查监听地址",
   "applies": [
    "kind:agent",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-038"
  },
  {
   "id": "RUN-039",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "本机 HTTP 控制接口必须用不可预测的令牌鉴权，并校验 Host 和 Origin，防 DNS 重绑定和跨站请求；不用 `Access-Control-Allow-Origin *`",
   "level": "hard",
   "check": "auto",
   "how": "测试无令牌、错误 Host、外部 Origin、跨域预检的请求都被拒绝",
   "applies": [
    "kind:agent",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-039"
  },
  {
   "id": "RUN-040",
   "topic": "32",
   "topicTitle": "在本机安全运行编码 Agent",
   "title": "能不开端口就不开：优先用标准输入输出、框架自带的进程间通信或 Unix 套接字；开了端口的，GET 请求不得有副作用，危险操作要用户确认",
   "level": "soft",
   "check": "semi",
   "how": "评审时列出所有监听端口及理由；测试 GET 请求不改变状态",
   "applies": [
    "kind:agent",
    "platform:desktop"
   ],
   "url": "https://plumbrule.dev/topics/local-agent-runtime/#run-040"
  }
 ]
}