结论先说:把「不可做什么」写进代码,比把「能做什么」写进 Prompt 可靠。权限目录 YAML 化、启动期自检、服务端两层裁决、失败一律 fail-closed——这些不产生演示效果,但决定了系统敢不敢交给现场。

一、Agent 只能提议,不能放行

9 个人工在环槽位 × 3 个动作(propose / cosign / approve)= 27 条权限,对应 32 个治理角色,全部声明在一份 YAML 里。权限注册表在构造期自检:Agent 身份只允许 `*.propose`,配置里一旦出现 `*.approve`,进程直接启动失败。

为什么做得这么绝:审批是责任,不是流程。让模型点「同意」,出问题时没人能解释它为什么同意。Agent 负责取证、拟稿、发起;人负责签字。

坑点:只在界面上隐藏审批按钮没用,`curl` 照样能调。所以权限分两层,且都在服务端——导航按岗位收敛,待办按令牌裁决;手输 hash、深链接同样回落。

二、拒绝要拒绝得干净

权限失败不是一种状态,而是三种:无令牌 401;有令牌但无权限 403;跨域工单详情 404。

404 不是礼貌,是不确认存在性。403 会告诉对方「这张单存在,只是你不能看」,本身就是信息泄漏。案例检索同理:无权案例统一返回「不存在或不可见」,候选不足时返回空结果和隐藏计数,绝不放宽过滤重试。

坑点:很多系统把「没权限」统一成 200 空列表。前端看起来正常,权限边界却已经漏了。

三、演示口径与真实口径必须分列

演示首页的 OTD 是 92.4%,而按种子数据、真实链路算出来的是 71.4%。两个数都不算错,错的是把它们混在一起。

做法很土:在线时简报不再拼演示预警;指标卡带「演示配置」角标;所有演示接口响应带 `X-Demo-Mode` 头;场景屏首帧必须挂「演示数据」徽标,漏挂直接拒绝渲染。

坑点:一旦人事在首页看到一条不存在的订单风险,后面所有数字都要重新解释。信任是一次性的。

四、推错群比不推更贵

客诉 8D 要推现场看板,先判责任工段。归因顺序固定为:显式工段 → 产线前缀 → 批次反查。三层都判不出来,就返回 `data_gap` 转人工分派,不硬推。

推送状态机是 `pushed → acked → closed`:非法流转直接 409;重推只刷新内容,不把现场已推进的状态打回;工段白名单在加载期校验,写错一个字母启动失败。IM 映射同理——结构在 YAML,凭据在环境变量,投递逐条记账,缺凭据就写明「未发送」,不假装发出去了。

坑点:自动化系统的价值不是「全自动」,而是「该停的时候停得住」。

五、把判断写成测试

项目有 1,896 条离线测试,CI 在 Python 3.11 / 3.12 / 3.13 三个版本跑。其中一类叫「架构守卫」,专门守上面这些决定:

  • 权限目录与导航岗位交叉校验,两边不能各自漂移;
  • 看板无权限必须 403,不许降级成演示数据;
  • 待办可见性在 HTTP / MCP / A2A 三条通道同一口径;
  • 未登记的看板数据范围直接 fail-closed,不默认放行。

坑点:测试不是覆盖率数字。能把「当初为什么这么定」固化成断言,才是资产——否则下一个人重构时会好心地帮你把防线拆掉。

如果重来一次

  • 先写拒绝清单,再写功能清单。哪些事系统永远不做,比它能做多少事更重要。
  • 权限判断只放服务端。前端只负责如实展示裁决结果,包括「已脱敏 N 项」。
  • 演示数据从第一天就带标记。徽标、响应头、种子开关,不要等上线前补。
  • 给每个自动动作配一个 `data_gap` 出口。判不出来就交给人,比硬猜便宜。