@sema-agent/server 7.20.0 → 7.21.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/USAGE.md +31 -3
  2. package/dist/adoption/runner.js +4 -5
  3. package/dist/approval-card.d.ts +52 -0
  4. package/dist/approval-card.js +108 -0
  5. package/dist/boot/governance-seams.d.ts +112 -0
  6. package/dist/boot/governance-seams.js +151 -0
  7. package/dist/boot/leader.d.ts +3 -0
  8. package/dist/boot/leader.js +7 -1
  9. package/dist/boot/resolve-spec.d.ts +3 -0
  10. package/dist/boot/resolve-spec.js +10 -2
  11. package/dist/boot/runner-deps.d.ts +6 -2
  12. package/dist/boot/runner-deps.js +13 -0
  13. package/dist/boot/stores.d.ts +1 -0
  14. package/dist/boot/stores.js +24 -5
  15. package/dist/config-types.d.ts +50 -4
  16. package/dist/config.js +129 -3
  17. package/dist/fleet/fleet-bus.d.ts +23 -2
  18. package/dist/fleet/fleet-bus.js +41 -2
  19. package/dist/fleet/subagent-tail-bus.d.ts +59 -0
  20. package/dist/fleet/subagent-tail-bus.js +60 -0
  21. package/dist/http/routes/capabilities.js +27 -0
  22. package/dist/http/routes/diagnostics.js +6 -0
  23. package/dist/http/routes/runs.js +10 -1
  24. package/dist/http/routes/tasks.js +15 -4
  25. package/dist/http/server.d.ts +5 -0
  26. package/dist/http/server.js +110 -7
  27. package/dist/leader/wire.d.ts +7 -1
  28. package/dist/leader/wire.js +12 -5
  29. package/dist/main.js +33 -8
  30. package/dist/memory-posture.d.ts +80 -0
  31. package/dist/memory-posture.js +40 -0
  32. package/dist/observability/fail-open.d.ts +4 -0
  33. package/dist/observability/fail-open.js +4 -0
  34. package/dist/orchestration/workflow-completion-inbox.d.ts +2 -1
  35. package/dist/parked-decide.js +9 -0
  36. package/dist/plugins/approval-ask-store-sql.d.ts +3 -1
  37. package/dist/plugins/approval-ask-store-sql.js +5 -6
  38. package/dist/plugins/background-agent-store-sql.js +4 -1
  39. package/dist/plugins/checkpoint-store-sql.d.ts +2 -0
  40. package/dist/plugins/checkpoint-store-sql.js +4 -5
  41. package/dist/plugins/file-run-store.d.ts +2 -1
  42. package/dist/plugins/image-bake-store-sql.js +5 -5
  43. package/dist/plugins/memory-embedder-fingerprint.d.ts +23 -0
  44. package/dist/plugins/memory-embedder-fingerprint.js +28 -1
  45. package/dist/plugins/memory-engine-tidb.js +3 -5
  46. package/dist/plugins/memory-engine-vector-util.d.ts +0 -5
  47. package/dist/plugins/memory-engine-vector-util.js +10 -4
  48. package/dist/plugins/memory-run-store.d.ts +2 -1
  49. package/dist/plugins/pg-session-storage.js +3 -3
  50. package/dist/plugins/run-store-sql.d.ts +12 -3
  51. package/dist/plugins/run-store-sql.js +4 -3
  52. package/dist/plugins/session-policy-store-sql.d.ts +1 -0
  53. package/dist/plugins/session-policy-store-sql.js +3 -3
  54. package/dist/plugins/shared-memory-store-sql.js +4 -10
  55. package/dist/plugins/sql-driver.d.ts +7 -5
  56. package/dist/plugins/sql-errors.d.ts +41 -0
  57. package/dist/plugins/sql-errors.js +25 -0
  58. package/dist/plugins/tidb-session-storage.js +3 -3
  59. package/dist/plugins/workflow-run-store-sql.js +4 -4
  60. package/dist/run-local.js +15 -0
  61. package/dist/runs.js +76 -38
  62. package/dist/task-mcp.d.ts +22 -1
  63. package/dist/task-mcp.js +71 -1
  64. package/dist/tool-approval.d.ts +35 -0
  65. package/dist/tool-approval.js +4 -1
  66. package/dist/trace/core-keyset-guard.d.ts +2 -2
  67. package/dist/trace/ledger-events.d.ts +43 -0
  68. package/dist/trace/ledger-events.js +2 -0
  69. package/dist/trace/ledger-sink.d.ts +3 -2
  70. package/dist/trace/project.d.ts +4 -1
  71. package/dist/trace/project.js +15 -0
  72. package/package.json +2 -2
package/USAGE.md CHANGED
@@ -153,6 +153,7 @@ MODEL_CODE_ROLES=default,subagent # 不设=全中立;仅这些角色在「
153
153
  **`MEMORY_PERSISTENCE_CAPABLE`(三态,7.14.0 起)**=**部署自述**「本机能不能把用户的『记住 X』落到持久处」,与请求键 `memoryWrite` 两轴正交(后者收紧本次运行,前者是 operator 声明;任一取否即只读方向)。缺省不设=引擎按 roster 自行推断;`true`=本部署有推断看不见的持久通道(自定义 writer / 记忆 MCP / 远程执行车道与记忆根共享挂载);`false`=强制只读披露并关掉文件工具往记忆根的写通道。不认得的词拒启。
154
154
  🔴 **`false` 自 core 5.27.0(7.14.0 提货)起还多一层——但只在 file 记忆引擎上**(`MEMORY_ENGINE_BACKEND` 未设 = 默认单机形):该会话是**受限会话**——materialize/搜索/harvest 一律按**已提交账**供给,记忆根下无事务背书的磁盘分歧既不收编也不供给,而是留盘 + 响亮点名(`restricted_divergence`,本服务把它计进 `memory_harvest_rejections_total{code}` 并 warn 一条),等下一次**非受限**会话走正常门收编。合法流程不受影响:用户手改、`git pull` 落下的删除照旧(延后,不销毁),并发的可写会话提交的变更算有事务背书。
155
155
  ⚠️ **`MEMORY_ENGINE_BACKEND=pg|tidb` 上没有这一层**(引擎的受限视图是 file 后端的实装):那两条腿的读侧本来就走库、盘上目录只是每任务的投影工作区,不存在「读磁盘即收编」的通道,所以也无洞可堵——`false` 在它们上仍然照常买到披露、写门与零收编 harvest 三件(与后端无关)。
156
+ 🔴 **降级前必须先停写排空(core 5.33.0 / server 7.21.0 起,运维 BREAKING)**:file 记忆后端的账本升到 **schema v2**(slug/scope rebind 与跨 scope transfer 改成 journaled 事务),而 **v2 journal 被 ≤5.32 的旧读者响亮拒**——这是**有意的 fail-closed**(拒启比静默读半张账好)。⇒ 要把服务降回 <5.33 的镜像:①**先停写、把在跑的记忆写排空**;②按 core 设计稿 §3.6 用 `transfers.jsonl` + journal 回放清账;③**再**降版。**排空这一步没做就降级 = 数据面响亮拒,不是静默丢**(三步全文见黑板 [3935])。`MEMORY_ENGINE_BACKEND=pg|tidb` 的部署不受此条约束(账本在库里,不走 file journal)。
156
157
  ⚠️ **口径别混**:「受限」是**会话级的这一个声明**;请求键 `memoryWrite:false` 铸出的只读**平面**(以及 org 层默认只读、双根非写面)**保持原样的 adopt-on-read**——它只是不 harvest,照旧看得见盘上的新内容。
157
158
  ⚠️ **core 5.26.0 起,remote 执行车道上的 `# Memory` 写指令被撤**(沙箱手带够不着 host 记忆根;读与注入不变)。**判据是「`REMOTE_EXEC` 有没有设」,不是那个旗**——凡设了 `REMOTE_EXEC`(含 `host`)且这次任务真有记忆面,写指令就被撤。因此被打到的**不止**显式 `MEMORY_ENGINE_REMOTE_LANE=allow` 的部署,还包括:① `MEMORY_ENGINE_BACKEND=pg|tidb` 的 DB 记忆面(它不受那个旗管辖——旗只管 file 引擎,库是持久真身,任何车道上都照常点亮)= 最常见的云形;② `REMOTE_EXEC=host` + file 引擎(文件平面就在本机,但 core 的执行环境判决仍是 remote)。若该车道确实够得着记忆根,`MEMORY_PERSISTENCE_CAPABLE=true` 是官方恢复路径(启动日志 `memory_remote_lane_write_instruction_dropped` 会点名这一条);够不着就表 `false`,让只读状态对模型说明白。
158
159
  另:记忆**没有写工具**,模型是用普通文件工具往记忆根写的——所以记忆根必须落在任务的 fs 授权边界内。结构性够不着时启动会 warn 并给出三条改法(把根挪进 workspace / 经 `additionalDirectories` 授权 / 声明 `MEMORY_PERSISTENCE_CAPABLE=false` 让披露诚实)。⚠️ **「挪根」用哪个旋钮按记忆后端分腿**:file 引擎是 `MEMORY_ENGINE_DIR`;`MEMORY_ENGINE_BACKEND=pg|tidb` 的根是 `<数据根>/memory-work`(数据根 = `LOCAL_DATA_ROOT`/`AGENT_DATA_DIR` 族),那两条腿**不读** `MEMORY_ENGINE_DIR`——启动 warn 的文案自己会按腿指对旋钮。
@@ -169,13 +170,14 @@ MODEL_CODE_ROLES=default,subagent # 不设=全中立;仅这些角色在「
169
170
  ⚠️ **半配 = 拒启**:上表前三键是一个整体,少任何一件都启动报错并**点名缺哪几键**。静默忽略的后果不是「功能缺席」而是「operator 以为向量面开着」——检索照常出结果(词面档),差异只在答案质量里,零日志。同理:只配 `TIMEOUT`/`API_KEY` 而三键不全、或 `MEMORY_ENGINE=off` 却配了 embedder,都是死旋钮,一律拒启。
170
171
  ⚠️ **维度守卫是拒启/抛错,不是 warn**:向量列写错维度是**静默毒库**——本仓 pg 记忆表的 embedding 列是 `jsonb`(无维度约束),错长度向量既不会让 DB 报错,也不会让检索报错(那些行只是悄悄退回词面档)。所以坏 `DIM`(非正整数)启动即拒;运行期端点返回的向量长度与 `DIM` 不符,embedder **直接抛错**,绝不截断/补零。换 `MODEL`/`DIM` 的**清空由指纹门自动做**(见下条),不必手动清。
171
172
  ⚠️ **embedder 出故障的方向**:端点 5xx/超时会让当次记忆**写入**报一条 `io error: …` 冲突(不写坏向量),检索腿则退回词面档继续服务——即「坏了退回 lexical」,不是「坏了写坏数据」。故障有专门信号:metric `memory_embed_failed_total{backend}` + 日志 `memory_embedder_failed`(只看 PatchReport 里的冲突会把供应商故障误读成并发改动)。
172
- ⚠️ **只对新写入的条目生效(没有回填腿)**:开启前就存在的记忆条目 embedding 列是空的,只有被再次写入时才会补上向量;这些行在检索里按词面档参与排序(不会被丢掉,但也享受不到向量档)。要立刻全量生效,得自己重写一遍语料。
173
+ ⚠️ **只对新写入的条目生效(没有回填腿)**:开启前就存在的记忆条目 embedding 列是空的,只有被再次写入时才会补上向量;这些行在检索里落**词面档**。🔴 **词面档不是「排名靠后」,是有门槛**:与查询**零词交集**的行被直接**丢出结果集**(词面距离在无交集时无定义,检索当场跳过该行),而带向量的行有余弦读数可用、**不会被这道门丢掉**(前提是 embedder 当时也把**查询**嵌出来了;embedder 故障那一刻查询侧拿不到向量,整轮检索退回词面档,于是零交集的行连同它们一起被丢)。所以没有向量的行对**改述 / 同义 / 跨语言**这类查询**检索不到**——那恰恰是向量档买的东西。要立刻全量生效,得自己重写一遍语料。
173
174
  🔴 **换 `MODEL`/`DIM` 的行为:下次 boot 自动清空向量列(embedder 指纹门,7.15.0 之后的下一个发布起)**。本服务在 `agent_memory_engine_meta` 单行元表里记住当前 embedder 的**指纹** `{model, dimensions}`(明文,便于排障;**endpoint 不进指纹**——换域名/代理不换语义空间)。每次启动比对一次:
174
175
  - **相等** ⇒ 零动作、零写;
175
176
  - **不等 / 元表还没有这一行而库里已有向量 / 那一行读不出来**(手改、回滚残留)⇒ **先把 `agent_memory_engine_entry.embedding` 整列清成 NULL,再写新指纹**(次序不可换:反过来若中途崩溃,新指纹已记而旧向量还在,此后每次 boot 都判「相等」,残留永不复检);
176
- - 启动日志 `memory_embedder_identity_changed` 带**旧值/新值/清了几行/耗时 ms/原因**(`changed` | `unattributed` | `unreadable`),首次记录指纹是 `memory_embedder_identity_armed`;比对腿**自身失败一律拒启**(吞掉 = 门在场却没跑成)。⚠️ 这条行按「identity 变了」发,**不是**按「清了几行」发:当时库里恰好没有向量可清(上一轮已清过 / 缺席窗内被写成 NULL)照样发,`cleared: 0` —— 换 embedder 这件事在启动日志里不该无痕。真正零响亮的只有「相等」那一档。
177
- - 清空**只丢可再生的向量缓存**(正文/frontmatter 分毫不动);清完**不回填**,行被下次写入时自然按新模型重嵌,期间检索退回词面档。⚠️ **误配代价诚实说**:把 `MODEL` 改错一次再改回来,向量**不会自动回来**——只随行被再次写入才重嵌;对写完就不动的记忆库,误配一次 = 向量面实质长期缺席(正文零损失,检索退 lexical)。要立刻恢复只能自己重写一遍语料。
177
+ - 启动日志 `memory_embedder_identity_changed` 带**旧值/新值/清了几行/耗时 ms/原因**(`changed` | `unattributed` | `unreadable`),首次记录指纹是 `memory_embedder_identity_armed`;比对腿**自身失败一律拒启**(吞掉 = 门在场却没跑成)。⚠️ 这条行按「identity 变了」发,**不是**按「清了几行」发:当时库里恰好没有向量可清(上一轮已清过 / 缺席窗内被写成 NULL)照样发,`cleared: 0` —— 换 embedder 这件事在启动日志里不该无痕。真正零响亮的只有「相等」那一档。⚠️ **这句承诺以「进程活到把日志发出来」为界**:清列与写元表是两条各自提交的语句,日志在两步都完成之后才发,所以进程恰好在清完之后崩溃(或被杀),下次启动会按当时的真实状态重判(报「首次记录指纹、没有向量可清」,或者元表已写完就直接判「相等」不发声)——**那一次清掉了多少行会从日志里丢失**。数据面不受影响(次序钉保证的就是这个:向量已 NULL、不会残留旧空间),丢的只是审计行;要精确到行数的审计请以 DB 侧的变更记录为准。
178
+ - 清空**只丢可再生的向量缓存**(正文/frontmatter 分毫不动);清完**不回填**,行被下次写入时自然按新模型重嵌,期间检索退回词面档。🔴 **代价按上面那条读**:退词面档 = 与查询零词交集的行**检索不到**(不是排到后面),所以清空之后、重嵌之前,那些行对改述/同义/跨语言查询是**不可达**的。⚠️ **误配代价诚实说**:把 `MODEL` 改错一次再改回来,向量**不会自动回来**——只随行被再次写入才重嵌;对写完就不动的记忆库,误配一次 = 向量面实质长期缺席(正文零损失,检索退 lexical,零交集查询够不着)。要立刻恢复只能自己重写一遍语料(或接受词面档)。
178
179
  - ⚠️ **换 embedder 请停机换:先 `scale 0`,再起**。指纹门管的是 **boot 面**;**滚动升级窗**里新副本清完列之后,仍在服役的旧副本还在按**旧**模型写向量,滚动结束后表里混着两个空间而元表已是新指纹 ⇒ 此后恒判「相等」,门再也不会复检。这是纪律面的残余,不假装根治(行级 embedder 记账列可结构性根治,超出本件射程)。滚动事故后的补救 = 手动清一次:`UPDATE agent_memory_engine_entry SET embedding = NULL WHERE embedding IS NOT NULL;`(同一条也可用于「误配后立刻重置」)。
180
+ 🔴 **同族的第二格:回滚到指纹门之前的版本**(7.14.x / 7.15.x —— 有 embedder、无指纹门)。那些版本照写 `embedding` 列却**根本不认识这张元表**,所以「装本版(元表记 B)→ 回滚到旧版跑一段(往列里写 A 空间向量,元表纹丝不动)→ 再升回本版、配置仍是 B」这条路走完,元表与配置都说 B 而列里躺着 A 空间向量 ⇒ 此后恒判「相等」,门再也不会复检。**跨门回滚与滚动窗要做同一件事**:回滚之前(或重新升级之后)手动跑上面那条清列 SQL,或干脆 `DELETE FROM agent_memory_engine_meta WHERE meta_key = 'embedder_identity';` 让下次启动按「无主向量」保守清一次。
179
181
  - **没配 `MEMORY_EMBEDDER_*` 时门整个不装**(元表不动,旧指纹保留),启动日志 `memory_embedder_identity_absent` 说明:缺席期间**被改写过**的行,其 embedding 本来就被写成 NULL,所以「配置回归同一 identity 后向量续用」只对缺席期间**未被触碰**的行成立。
180
182
  - 残余:同名模型在不同供应商若其实是不同实现(自托管 finetune 撞名),指纹辨不出——明知异实现时改 `MEMORY_EMBEDDER_MODEL` 名,或手动跑上面那条清列 SQL。`MEMORY_ENGINE_BACKEND=tidb` 无向量面(v1 词面档),没有这张元表也没有这道门。
181
183
  📋 **档位对运维可见**:启动日志 `memory_engine_enabled` 行带 `vectorMode` 字段(取自后端实例的真值,不是配置推断)——`lexical` 在跑就必须看得见;配了 embedder 时同行还带 `embedderModel`/`embedderDim`,换模型这件事在日志里留痕。
@@ -324,6 +326,8 @@ STREAM_APPROVAL_PENDING_GRACE_MS=30000
324
326
  # adhoc 腿的窗后宽限(毫秒,默认 60000,有界 [0,86400000])
325
327
  STREAM_APPROVAL_ADHOC_GRACE_MS=60000
326
328
  # 遗孤最终可判上界(毫秒,默认 604800000=7d,有界 [60000, 7776000000=90d]),量的是 createdAtMs
329
+ # 单机交互形推荐调低到 172800000(48h):一张卡挂两天没人批在单机上基本等于死卡,7d 默认是按
330
+ # 多副本云形定的;调低让 resume 对账少扫陈年行([3899]④/[3900] 线,clay 裁=文档推荐不改全局默认)
327
331
  STREAM_APPROVAL_ORPHAN_TTL_MS=604800000
328
332
  ```
329
333
  - **坏形一律启动期炸,不静默折默认**:五个 `numEnv` 键(`STREAM_ASK_WINDOW_MS` / `STREAM_ASK_WINDOW_MARGIN_MS` /
@@ -602,6 +606,30 @@ curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
602
606
 
603
607
  > **operator 鉴权(审批队列)**:`OPERATOR_PRINCIPALS=ops:alice,ops:bob`(CSV)= 谁能当 operator——列任意 owner 待办 + 决议(批/否)。**单租户部署不设=旧行为**(握 service token 即 operator,向后兼容);设了之后,非名单 principal 列待办只看自己的、且**不能决议**(403,防"请求方批自己的高危操作"绕过 F4 闸)。⚠️ **多租户形拒启**(#157-①):`DURABLE_APPROVAL=true` + `REQUIRE_PRINCIPAL=true` 而 `OPERATOR_PRINCIPALS` 空 ⇒ 进程启动失败并点名修法——否则空名单会让任一已验证租户读到其他租户的待批队列(读面 true-for-all)。设名单,或确属单租户则不设 `REQUIRE_PRINCIPAL`。
604
608
  >
609
+ > **部署治理三声明(7.21.0 起,design/170 件B/C/D)**——三根都是**配置期**旋钮,「谁能改它们」≡「谁能改
610
+ > 部署配置」,不引入新鉴权面;三根**都不设 = 现行为逐字不变**,坏值一律**拒启**(安全轴上的旋钮不静默失效)。
611
+ > - `COMPLIANCE_PROFILE=hipaa|zdr`(+ 可选 `COMPLIANCE_ADDITIONAL_DENIES=mcp_servers,workflows,web_fetch,org_memory_mount`,
612
+ > **只能在档位内建 floor 上加禁,不能减**):合规档位否决。档位在场时被禁能力不挂载,任务**显式请求**被禁能力
613
+ > ⇒ 响亮拒(prepare 期终态 `config.compliance_denied`)。hipaa 内建禁 `web_fetch`+`org_memory_mount`,zdr 内建禁
614
+ > `org_memory_mount`。⚠️ 档位是**部署级**(对本部署所有 principal 同一份)——per-principal 档位需要中心侧下发面,
615
+ > 那一面尚未建;拼错档位名或能力名一律拒启(拼错=「标着 hipaa 却根本没在执行」)。
616
+ > 🔴 **装配相容性拒启**(不是配置洁癖,是保护):被禁能力若在本部署的装配里**恒在场**,引擎会**整拒**
617
+ > 每一条腿(不是静默不挂载),所以这两形在启动期就拒:①禁 `mcp_servers` 而部署自己配了 MCP 服务器;
618
+ > ②禁 `web_fetch`(**`hipaa` 的内建 floor 就含它**)而本部署是**单用户**形——单用户花名册恒挂 WebFetch
619
+ > (多租户形才会把它摘掉)。也就是说 **`COMPLIANCE_PROFILE=hipaa` 目前只能跑在 `REQUIRE_PRINCIPAL=true`
620
+ > 的部署上**;拒启文案会逐条说明改法。
621
+ > - `LOCKED_CONFIG_KEYS=mcp,compliancePosture,retentionPolicy`(闭集:`mcp`/`toolPolicy`/`compliancePosture`/`retentionPolicy`):
622
+ > 管理员锁定层。锁了 `mcp` ⇒ 任务自带 `mcpServers` 被**同步 400 整拒**(`errorCode:"config.locked_key"`,不静默丢)。
623
+ > ⚠️ **本服务拒绝两把装不上的锁**:①`toolPolicy`——本服务每条腿都自铸有效工具策略(部署审批基线 + tighten-only
624
+ > 治理拍),锁上会让**每一条**任务在引擎门口被拒,而那把锁本就无物可守(策略已是部署所有、请求放松不了);
625
+ > ②`mcp` + 部署自己配了 MCP 服务器——部署的服务器走同一个字段,锁上同样每条腿必拒。两者都在**启动期**点名拒,
626
+ > 不留给运维在每条任务上猜。
627
+ > - `RETENTION_MAX_AGE_DAYS=<非负数>`:托管留存期**声明**。⚠️ **不要读成「数据会按期删」**:本版没有任何 store
628
+ > 声明托管留存能力、也没有留存调度面,所以这根旋钮目前只做两件事——喂引擎的启动期能力校验(把
629
+ > `retentionPolicy` 一并写进 `LOCKED_CONFIG_KEYS` 而 store 不能删 ⇒ **拒启**,杜绝「策略锁着、数据永存」),
630
+ > 以及在配了策略却无执行面时打一条启动 warn(`retention_policy_not_executed`)。真执行(调度/重试/副本协调/
631
+ > 逐行审计)是后续批。
632
+
605
633
  > **⚠️ hook-wired 部署里,parked 后台子代可能赎回不了(常态,不是升级窗口)。**
606
634
  > 引擎 5.19.0 起,一个任务的 **PreToolUse screening 面下延管辖它委派出去的子代**,于是 hook-wired 父
607
635
  > 派出的子代 park 时,checkpoint 记的祖先约束层数是 **2**(screening 席 + 父自己的策略席);而本服务的
@@ -29,11 +29,10 @@ import { AFFECTED_CONFIG_TEMPLATES, MEMORY_SCOPE_MAX_CHARS, NOT_MIGRATED_BY_DESI
29
29
  import { buildBlobScanSql, buildBlobWriteSql, buildConflictProbeSql, buildMemoryScopeScanSql, buildResidualCountSql, buildRebindSql, buildRuleOwnerRekeySql, buildSessionPolicyRewriteSql, buildSessionPolicyScanSql, } from "./sql.js";
30
30
  import { AdoptionConfigsSchema, AdoptionLegsSchema, AdoptionReceiptSchema, AdoptionRejectDetailSchema, AdoptionReportSchema, } from "./wire.js";
31
31
  import { formatUserScope, encodeScopeSegment } from "@sema-agent/core";
32
- /** MySQL `ER_DUP_ENTRY` / PG `unique_violation` —— 跨轴组合冲突的最后一道 fail-closed 门。 */
33
- function isDupKey(dialect, err) {
34
- const e = err;
35
- return dialect === "tidb" ? e?.errno === 1062 : e?.code === "23505";
36
- }
32
+ import { isDupKeyError } from "../plugins/sql-errors.js";
33
+ /** MySQL `ER_DUP_ENTRY` / PG `unique_violation` —— 跨轴组合冲突的最后一道 fail-closed 门。
34
+ * 判据属主 = `plugins/sql-errors.ts`(A-032 P1-①:本站点旧形只认 errno,只带 `code` 的驱动错误漏判)。 */
35
+ const isDupKey = isDupKeyError;
37
36
  function msgOf(e) {
38
37
  return e instanceof Error ? e.message : String(e);
39
38
  }
@@ -91,6 +91,36 @@ export declare const RuleSuggestionRawSchema: z.ZodObject<{
91
91
  * 变成铸卡失败——在一条纯展示轴上换来一次 park,方向反了)。⇒ 消费端按 **≤4** 布局,契约文同口径成文。
92
92
  */
93
93
  export declare const MAX_RULE_SUGGESTIONS = 4;
94
+ /**
95
+ * #253 件 G1 —— `probeCause` 的形(落库/读面共用这一份;窄读入口是 {@link readProbeCause})。
96
+ *
97
+ * `.strict()` 与卡的其余部分同调:落进 `card_json` 的东西是**本仓投出来的**三键,不可能有未知键
98
+ * (未知键在窄读那一层就被 zod 默认 strip 掉了)。
99
+ */
100
+ export declare const ProbeCauseSchema: z.ZodObject<{
101
+ code: z.ZodString;
102
+ roots: z.ZodObject<{
103
+ shown: z.ZodArray<z.ZodString>;
104
+ total: z.ZodNumber;
105
+ }, z.core.$strict>;
106
+ further: z.ZodOptional<z.ZodObject<{
107
+ shown: z.ZodArray<z.ZodString>;
108
+ total: z.ZodNumber;
109
+ }, z.core.$strict>>;
110
+ }, z.core.$strict>;
111
+ export type ProbeCauseProjection = z.infer<typeof ProbeCauseSchema>;
112
+ /**
113
+ * `AskRequest.probeCause`(以及耐久路同值的 `RiskDescriptor.probeCause`)的**边界窄读** —— 活卡帧与
114
+ * `card_json` 的**唯一**铸造点,两面因此结构性同值(不是两处各挑一次键的巧合)。
115
+ *
116
+ * 🔴 姿势与 `RiskAxesEnvelopeSchema` 一致:`safeParse` 而不是裸 `as`(宪法 [2704] 边界必 schema)。
117
+ * 形不合 ⇒ **按缺席处置**(绝不半解出一个残缺结构上卡面);未知键被 zod 默认 strip(core additive
118
+ * 加键不该让整只 ask 的卡面塌掉)。
119
+ *
120
+ * 🔴 截长的两条不同判据(理由全文见 {@link MAX_PROBE_CAUSE_CODE} 顶注):`code` 超限 ⇒ **整只丢**
121
+ * (截出来的是另一个机器码);`shown` 超限 ⇒ **截条目**,`total` 逐字保真。
122
+ */
123
+ export declare function readProbeCause(req: unknown): ProbeCauseProjection | undefined;
94
124
  /**
95
125
  * design/172 §3.1 的**中性投影**——呈卡面看到的全部内容,与 wire 面的既有 `ToolApprovalFrame` 解耦。
96
126
  *
@@ -124,6 +154,17 @@ export declare const ApprovalCardSchema: z.ZodObject<{
124
154
  }>;
125
155
  command: z.ZodString;
126
156
  }, z.core.$strict>>>;
157
+ probeCause: z.ZodOptional<z.ZodObject<{
158
+ code: z.ZodString;
159
+ roots: z.ZodObject<{
160
+ shown: z.ZodArray<z.ZodString>;
161
+ total: z.ZodNumber;
162
+ }, z.core.$strict>;
163
+ further: z.ZodOptional<z.ZodObject<{
164
+ shown: z.ZodArray<z.ZodString>;
165
+ total: z.ZodNumber;
166
+ }, z.core.$strict>>;
167
+ }, z.core.$strict>>;
127
168
  fromSubagent: z.ZodOptional<z.ZodLiteral<true>>;
128
169
  sourceTaskId: z.ZodOptional<z.ZodString>;
129
170
  sourceAgentName: z.ZodOptional<z.ZodString>;
@@ -164,6 +205,17 @@ export declare const ApprovalCardEnvelopeSchema: z.ZodObject<{
164
205
  }>;
165
206
  command: z.ZodString;
166
207
  }, z.core.$strict>>>;
208
+ probeCause: z.ZodOptional<z.ZodObject<{
209
+ code: z.ZodString;
210
+ roots: z.ZodObject<{
211
+ shown: z.ZodArray<z.ZodString>;
212
+ total: z.ZodNumber;
213
+ }, z.core.$strict>;
214
+ further: z.ZodOptional<z.ZodObject<{
215
+ shown: z.ZodArray<z.ZodString>;
216
+ total: z.ZodNumber;
217
+ }, z.core.$strict>>;
218
+ }, z.core.$strict>>;
167
219
  fromSubagent: z.ZodOptional<z.ZodLiteral<true>>;
168
220
  sourceTaskId: z.ZodOptional<z.ZodString>;
169
221
  sourceAgentName: z.ZodOptional<z.ZodString>;
@@ -83,6 +83,88 @@ export const RuleSuggestionRawSchema = RuleSuggestionSchema.extend({ rule: z.str
83
83
  export const MAX_RULE_SUGGESTIONS = 4;
84
84
  /** 已 redact 的 ask 文案上限(`AskRequest.message` 的投影)。 */
85
85
  const MAX_MESSAGE = 8192;
86
+ /**
87
+ * #253 件 G1(core 5.33.0 backlog #239)—— `probeCause` 的**防御性上限**三兄弟。
88
+ *
89
+ * 🔴 **它们不是第二个真源**:core 自己在 `normalizeProbeCause` 里已经 cap 过(亲读装树
90
+ * `dist/core/checkpoint-store.js`:`PROBE_REASON_MAX = 200` 管 code、`PROBE_CAUSE_PATH_MAX = 200`
91
+ * 管每一条 `shown`、`PROBE_CAUSE_MAX_SHOWN = 8` 管条数),而那三个常量**没有从包根导出**(亲验
92
+ * `index.d.ts` 零命中)⇒ 抄不了值,只能各写一份。所以这里的姿势是**信封而不是政策**:取与 core 同值
93
+ * (条数取 8),并由 `test/approval-probe-cause-wire.test.ts` 的「至少带到 8」一格看守 —— core 哪天放宽
94
+ * 条数,那一格仍绿(我们少带几条,`total` 逐字保真 ⇒ 消费端算得出「还有 N 条没点名」,不是谎报)。
95
+ *
96
+ * 为什么 `code` 超限是**整只丢**而不是截:`code` 是**机器可读**的因由 id,截出来的是**另一个**码 ——
97
+ * 消费端会把它映射到错误的文案,比没有更坏。`shown` 相反:少点名几条是诚实的省略(`total` 还在)。
98
+ */
99
+ const MAX_PROBE_CAUSE_CODE = 200;
100
+ const MAX_PROBE_CAUSE_PATH = 200;
101
+ const MAX_PROBE_CAUSE_SHOWN = 8;
102
+ /**
103
+ * #253 件 G1 —— 一族**操作数**(`roots` / `further`)的形。`shown` 是可点名的条目,`total` 是这一族的
104
+ * 真实基数。**不变量 `shown.length ≤ total`**:少报条目是诚实的省略(消费端渲「另有 N 条」),
105
+ * 谎报 `total` 是错的。上限见 {@link MAX_PROBE_CAUSE_SHOWN} 顶注(信封,不是政策)。
106
+ */
107
+ const ProbeCauseOperandsSchema = z
108
+ .object({
109
+ shown: z.array(z.string().max(MAX_PROBE_CAUSE_PATH)).max(MAX_PROBE_CAUSE_SHOWN),
110
+ total: z.number().int().nonnegative(),
111
+ })
112
+ .strict();
113
+ /**
114
+ * #253 件 G1 —— `probeCause` 的形(落库/读面共用这一份;窄读入口是 {@link readProbeCause})。
115
+ *
116
+ * `.strict()` 与卡的其余部分同调:落进 `card_json` 的东西是**本仓投出来的**三键,不可能有未知键
117
+ * (未知键在窄读那一层就被 zod 默认 strip 掉了)。
118
+ */
119
+ export const ProbeCauseSchema = z
120
+ .object({
121
+ code: z.string().min(1).max(MAX_PROBE_CAUSE_CODE),
122
+ roots: ProbeCauseOperandsSchema,
123
+ further: ProbeCauseOperandsSchema.optional(),
124
+ })
125
+ .strict();
126
+ /** 窄读的入参形:只判**结构**,长度由 {@link readProbeCause} 在判完之后自己截(与
127
+ * {@link RuleSuggestionRawSchema} 同款分工:先判形、再截,函数对自己的输出幂等)。 */
128
+ const ProbeCauseRawOperandsSchema = z.object({ shown: z.array(z.string()), total: z.number().int().nonnegative() });
129
+ const ProbeCauseEnvelopeSchema = z.object({
130
+ probeCause: z
131
+ .object({
132
+ code: z.string().min(1),
133
+ roots: ProbeCauseRawOperandsSchema,
134
+ further: ProbeCauseRawOperandsSchema.optional(),
135
+ })
136
+ .optional(),
137
+ });
138
+ /**
139
+ * `AskRequest.probeCause`(以及耐久路同值的 `RiskDescriptor.probeCause`)的**边界窄读** —— 活卡帧与
140
+ * `card_json` 的**唯一**铸造点,两面因此结构性同值(不是两处各挑一次键的巧合)。
141
+ *
142
+ * 🔴 姿势与 `RiskAxesEnvelopeSchema` 一致:`safeParse` 而不是裸 `as`(宪法 [2704] 边界必 schema)。
143
+ * 形不合 ⇒ **按缺席处置**(绝不半解出一个残缺结构上卡面);未知键被 zod 默认 strip(core additive
144
+ * 加键不该让整只 ask 的卡面塌掉)。
145
+ *
146
+ * 🔴 截长的两条不同判据(理由全文见 {@link MAX_PROBE_CAUSE_CODE} 顶注):`code` 超限 ⇒ **整只丢**
147
+ * (截出来的是另一个机器码);`shown` 超限 ⇒ **截条目**,`total` 逐字保真。
148
+ */
149
+ export function readProbeCause(req) {
150
+ const parsed = ProbeCauseEnvelopeSchema.safeParse(req);
151
+ const raw = parsed.success ? parsed.data.probeCause : undefined;
152
+ if (raw === undefined)
153
+ return undefined;
154
+ if (raw.code.length > MAX_PROBE_CAUSE_CODE)
155
+ return undefined;
156
+ const family = (f) => ({
157
+ shown: f.shown.slice(0, MAX_PROBE_CAUSE_SHOWN).map((p) => (p.length > MAX_PROBE_CAUSE_PATH ? p.slice(0, MAX_PROBE_CAUSE_PATH) : p)),
158
+ // 不变量兜底:上游若少报 total(`total < shown.length`),取二者较大者 —— 与 core 的
159
+ // `Math.max(total, shown.length)` 同向,永不让消费端算出负数的「另有 N 条」。
160
+ total: Math.max(f.total, Math.min(f.shown.length, MAX_PROBE_CAUSE_SHOWN)),
161
+ });
162
+ return {
163
+ code: raw.code,
164
+ roots: family(raw.roots),
165
+ ...(raw.further !== undefined ? { further: family(raw.further) } : {}),
166
+ };
167
+ }
86
168
  /**
87
169
  * design/172 §3.1 的**中性投影**——呈卡面看到的全部内容,与 wire 面的既有 `ToolApprovalFrame` 解耦。
88
170
  *
@@ -171,6 +253,26 @@ export const ApprovalCardSchema = z
171
253
  * 登记为后续件(车4 域)。
172
254
  */
173
255
  ruleSuggestions: z.array(RuleSuggestionSchema).max(MAX_RULE_SUGGESTIONS).optional(),
256
+ /**
257
+ * #253 件 G1(core 5.33.0 backlog #239,判据帖 [3930] G1;**ADDITIVE**)——这次 ask **为什么**被
258
+ * 收紧的**结构化**因由:`{ code, roots:{shown,total}, further?:{shown,total} }`。
259
+ *
260
+ * 🔴 **结构就是契约**(core d.ts 逐字:"render the entries as data, never re-derive structure by
261
+ * splitting or joining them"):`code` 是机器可读的因由 id(`<domain>.<snake_case>`),两个操作数族
262
+ * 各自带**真实基数** `total` —— `shown` 被省略时 `shown.length < total`,消费端据此渲「另有 N 条」。
263
+ * 引擎交出的是 code + 数组,**永不**是写好的句子;句子归呈卡端按本地语言渲。所以本层**逐字透传结构**,
264
+ * 不拍扁成文本、不重排、不合并两族。
265
+ *
266
+ * 🔴 **缺席 ≠「没有原因」**:只有「maybe 档收紧 ∧ 探针真给了 cause」的 ask 才有它。绝大多数 ask
267
+ * 压根不经这条路(缺席),而**判得出可回滚**的调用根本不会变成 ask。
268
+ *
269
+ * 🔴 与 `message` **刻意分列、不合并**(判据帖 G1 的负控):`message` 是分类器/模型看得见的输入面,
270
+ * 把一份随路径变化的操作数清单挤进去会污染判定输入;本键是**展示面**的结构化数据。
271
+ *
272
+ * 回滚窗代价与 `governanceForced` / `persistedRuleShadowed` **逐字同族**(见那两条的 ⚠️):
273
+ * `.strict()` 下旧二进制读不动带本键的新行 ⇒ 重放跳过 + 幂等重入走 park(fail-safe),`schemaVersion` 不动。
274
+ */
275
+ probeCause: ProbeCauseSchema.optional(),
174
276
  /** 委派出处(子代 ask 才在场;判别键 = `fromSubagent`,core RB-39②)。 */
175
277
  fromSubagent: z.literal(true).optional(),
176
278
  sourceTaskId: z.string().max(MAX_IDENT).optional(),
@@ -246,6 +348,9 @@ function clip(s, max) {
246
348
  export function buildApprovalCard(source, req, requiresRealApproval) {
247
349
  const axes = RiskAxesEnvelopeSchema.safeParse(req);
248
350
  const riskAxes = axes.success ? axes.data.riskAxes : undefined;
351
+ // #253 件 G1:`probeCause` 与两轴同款,从 `req`(core 的 AskRequest)窄读 —— 发帧点用**同一个**函数
352
+ // 铸帧上那一份,于是「帧与卡同值」是结构事实而不是两处各挑一次键的巧合(§6.5 同款判据)。
353
+ const probeCause = readProbeCause(req);
249
354
  const toolCallId = clip(source.toolCallId, MAX_IDENT);
250
355
  const sourceTaskId = clip(source.sourceTaskId, MAX_IDENT);
251
356
  const sourceAgentName = clip(source.sourceAgentName, MAX_AGENT_NAME);
@@ -265,6 +370,9 @@ export function buildApprovalCard(source, req, requiresRealApproval) {
265
370
  // #144:被越级的规则原文。**空串不投**(core 的四个 mint 点只在真有命中规则时带这个键 ⇒ 空串不是
266
371
  // 「有一条空规则」而是坏值;投一个空串会让壳渲一行「你的规则 ⟨⟩ 仍在」)。截长同 `message` 待遇。
267
372
  ...(shadowedRule !== undefined && shadowedRule !== "" ? { persistedRuleShadowed: shadowedRule } : {}),
373
+ // #253 件 G1:结构化探针因由 —— 与活卡帧**同一个**窄读函数(`readProbeCause`),两面结构性同值;
374
+ // 形不合/缺席 ⇒ 不铸键(语义见 `ApprovalCardSchema.probeCause` 顶注)。
375
+ ...(probeCause !== undefined ? { probeCause } : {}),
268
376
  // #154 车二:候选**逐字**投影(空数组 ⇒ 不投键 —— 「没有候选」与「本部署不供候选」在卡面上都读作
269
377
  // 「本卡无此选项」,铸一个空数组只会让消费端多一条无意义的分支)。拷贝一份:卡不与调用方共享可变引用。
270
378
  ...(source.ruleSuggestions !== undefined && source.ruleSuggestions.length > 0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * design/170 件B/C/D(#252 件1)—— 三个**部署治理座席**的装配点:把 core 5.13.0 起就存在、本仓一直
3
+ * 零接线的三个 `RunnerDeps` seam 接上。
4
+ *
5
+ * · 件B `compliancePostureResolver` —— per-principal 合规档位否决(闭集 profile × 闭集 capability;
6
+ * 生效 deny 集 = `BUILTIN_COMPLIANCE_DENIES[profile] ∪ additionalDenies`,**供数只能收紧**)。
7
+ * · 件C `lockedConfig` —— 管理员锁定层的**声明**(闭集注册表;spec 占了锁着的字段 ⇒ core 整拒 prepare)。
8
+ * · 件D `retentionPolicy` —— 托管留存期。core **只**拿它做启动期能力校验,引擎在任务路径上从不删数据。
9
+ *
10
+ * ## 真源:为什么三件都只接 env
11
+ *
12
+ * design/170 §1 的三层分工表里,center 那一列对 B/C/D 写的都是「**新建**」——中心侧的档位真源 / 策略
13
+ * 真源 / 保留期真源**至今不存在**。接一条 center 腿等于造一个恒缺席的假面(件A 的 org 目录能接 center,
14
+ * 是因为那一面真被建出来了)。所以本装配点的供数只有部署配置面,并且这**恰好**是 design/170 §4.3 对
15
+ * 「谁能解锁」的裁定:锁是**配置期物**,铸锁真源只有 center governance 域与部署 env,二者都是 operator
16
+ * 控制的配置面,自带各自既有鉴权 —— 「谁能解锁」≡「谁能改部署配置」,不引入新鉴权面。
17
+ *
18
+ * ## 件B 的窄化:档位是**部署级**,不是 per-principal
19
+ *
20
+ * core 的座席签名按 principal 取值,本仓的 resolver 对每个 principal 返回同一份部署档位。这是诚实的窄
21
+ * 实现:per-principal 档位需要 center 的下发面(§3.2 R5「与 caps 同一次 fetch」),那一面尚未建。
22
+ * 座席在场即恒生效,不存在「某些 principal 悄悄没档位」的分岔。
23
+ *
24
+ * ## 件B 的**不采纳**:多租户缺解析器不拒启(显式定界)
25
+ *
26
+ * §3.4 建议「部署自称多租户却未配 B 的解析器 ⇒ 拒启动」。**本仓不采纳**,理由是三问里的第二问:
27
+ * 谁被伤到 —— 今天每一个 `REQUIRE_PRINCIPAL=true` 的部署都没有这一格,采纳即是让它们**全部**在下一次
28
+ * 升级时起不来,而它们并没有任何东西变得更不安全(B 缺席 = 无合规限制 = 与今天逐字相同的行为)。
29
+ * 补偿是这条 env 旋钮本身:要档位的部署显式写下它。将来 center 真有了档位下发面,「多租户必须能解析出
30
+ * 档位」才是一条有对象可指的判据,那时再立。
31
+ *
32
+ * ## 件C 的两条**装配相容性**判据(本仓特有,见 {@link assertLockAssemblable})
33
+ *
34
+ * core 的锁按「spec 是否占了这个字段」判,而本仓是**部署自己**在铸那两个 spec 字段:
35
+ * · `spec.toolPolicy` —— 治理拍(`applyRuntimeGovernance`)在**任何**客户端表态下都会铸出它(敏感路径
36
+ * 守卫集默认非空);于是 `toolPolicy` 锁会让 core 的 preflight **整拒每一条腿**。
37
+ * · `spec.mcp` —— 部署自带的 MCP 基线(场景/center 下发)同样经 `spec.mcp` 进 core;部署配了 MCP 又锁
38
+ * `mcp`,同样是每条腿必拒。
39
+ * 两者都在**启动期**拒(安全控件不得半开 §3.4 C 行),而不是让运维在每条任务腿上各撞一次
40
+ * `config.locked_key` 去猜。
41
+ *
42
+ * ## 件D 的边界(成文,别读成「数据会被删」)
43
+ *
44
+ * 本仓**没有任何** store 声明 `retention:"managed"`(亲验:session/checkpoint/tool-result 三族 SQL 双生
45
+ * 与 local 形都没有这一位),托管留存的执行面(调度/重试/副本协调/逐行审计)按 §5.2 是「server 半场四件
46
+ * 皆新建」,不在本批。所以本装配点对件D 只做两件事:①把策略递给 core 的启动期校验
47
+ * (`assertRetentionCapability` —— 锁着 + 店不能删 ⇒ 拒启,「策略锁着、数据永存」不许发生);②策略在场
48
+ * 而没有任何 managed 店时打一条响亮 warn。**不**假装有执行面。
49
+ */
50
+ import { type LockedKey, type RunnerDeps } from "@sema-agent/core";
51
+ import type { ServiceConfig } from "../config.js";
52
+ import type { Logger } from "../observability/logger.js";
53
+ /** 装配产物:三个 core 座席 + 已校验的锁集(同步拒面 `assertRequestMcpUnlocked` 读它)。 */
54
+ export interface GovernanceSeams {
55
+ compliancePostureResolver: RunnerDeps["compliancePostureResolver"];
56
+ lockedConfig: RunnerDeps["lockedConfig"];
57
+ retentionPolicy: RunnerDeps["retentionPolicy"];
58
+ /** 校验过的锁集(空集 = 无锁)。**不是** `lockedConfig` 的复述:core 那一格是给引擎的声明,这一格是
59
+ * server 自己的同步拒面判据(§4.3「请求根本不该进 core」),两者同源于一次解析。 */
60
+ lockedKeys: ReadonlySet<LockedKey>;
61
+ }
62
+ /** 依赖收窄到真实消费面(接口隔离 —— 测试用真形字面量,零铸形)。 */
63
+ export interface GovernanceSeamsCtx {
64
+ config: Pick<ServiceConfig, "compliancePosture" | "lockedConfigKeys" | "retentionPolicy" | "mcpServers" | "requirePrincipal">;
65
+ logger: Pick<Logger, "info" | "warn">;
66
+ /**
67
+ * core 留存能力校验的对象集(名字进拒启文案)。喂的是**装配现场的真店**,不是「我以为装了什么」——
68
+ * 这条校验的全部价值就在于读的是真身上的声明位。
69
+ */
70
+ retentionStores: ReadonlyArray<{
71
+ name: string;
72
+ store: object | undefined;
73
+ }>;
74
+ }
75
+ /**
76
+ * 件C 的装配相容性判据(纯,可单测)——见文件头「两条判据」。
77
+ *
78
+ * 🔴 失败方向朝**拒启**:一把在本部署上必然把每条腿都拒掉的锁,不是「配得比较严」,是配错了;继续启动
79
+ * 只会让服务对每一个请求说 `config.locked_key`,而运维手里没有任何线索指向那把锁。
80
+ */
81
+ export declare function assertLockAssemblable(input: {
82
+ lockedKeys: ReadonlySet<LockedKey>;
83
+ deploymentMcpConfigured: boolean;
84
+ }): void;
85
+ /**
86
+ * 治理面的**装配相容性**总判据(codex 对抗复审 R2-F2 起扩面)——锁与合规档位在这条轴上是同一件事:
87
+ * 两者都可能把「本部署自己的装配」判成 core 门口的违规,而那不是「配得更严」,是**这台从此每条腿必失败**。
88
+ *
89
+ * 合规半场的判据来自亲核 core 的 prepare 门(`prepare-task` 的 compliance 段):被否决的能力若**已在
90
+ * spec 上**,core 是**整拒 prepare**(`config.compliance_denied`),不是「不挂载」。于是:
91
+ * · `mcp_servers` 被禁 ∧ 部署自带 MCP 基线 ⇒ 每条腿的 `spec.mcp` 都在 ⇒ 必拒;
92
+ * · `web_fetch` 被禁 ∧ 花名册挂了 WebFetch ⇒ 必拒。⚠️ `hipaa` 的**内建 floor 就含 `web_fetch`**,而本仓
93
+ * 的单用户花名册**恒挂** WebFetch(`capabilities/scenarios.ts` 的 roster 过滤只在多租户下摘它)——
94
+ * 也就是说「单用户 + hipaa」在本仓是一个每条腿必失败的组合。这条判据的全部价值就在这儿:让它在
95
+ * 启动期说出来,而不是让运维看着一台"启动成功、任务全失败"的 worker 猜。
96
+ * · `workflows` 被禁只在 `spec.selfOrchestration === true` 的腿上拒(per-task,不是部署恒真)⇒ 不在本
97
+ * 判据内,由那条腿自己响亮拒;`org_memory_mount` 走件A 的准入面。**成文定界,不留白。**
98
+ */
99
+ export declare function assertGovernanceAssemblable(input: {
100
+ lockedKeys: ReadonlySet<LockedKey>;
101
+ /** 已解析的合规否决集(空集 = 无档位)。 */
102
+ complianceDenies: ReadonlySet<string>;
103
+ deploymentMcpConfigured: boolean;
104
+ /** 本部署的花名册是否会挂 WebFetch(= 单用户形;多租户下 roster 过滤掉它)。 */
105
+ webFetchMounted: boolean;
106
+ }): void;
107
+ /**
108
+ * 装配三座席。**拒启口即装配口**:相容性判据与 core 的留存能力校验都在这里跑,调用方不必记得再调一次
109
+ * (件A 的 C12 探测同姿势)。
110
+ */
111
+ export declare function createGovernanceSeams(ctx: GovernanceSeamsCtx): GovernanceSeams;
112
+ //# sourceMappingURL=governance-seams.d.ts.map
@@ -0,0 +1,151 @@
1
+ /**
2
+ * design/170 件B/C/D(#252 件1)—— 三个**部署治理座席**的装配点:把 core 5.13.0 起就存在、本仓一直
3
+ * 零接线的三个 `RunnerDeps` seam 接上。
4
+ *
5
+ * · 件B `compliancePostureResolver` —— per-principal 合规档位否决(闭集 profile × 闭集 capability;
6
+ * 生效 deny 集 = `BUILTIN_COMPLIANCE_DENIES[profile] ∪ additionalDenies`,**供数只能收紧**)。
7
+ * · 件C `lockedConfig` —— 管理员锁定层的**声明**(闭集注册表;spec 占了锁着的字段 ⇒ core 整拒 prepare)。
8
+ * · 件D `retentionPolicy` —— 托管留存期。core **只**拿它做启动期能力校验,引擎在任务路径上从不删数据。
9
+ *
10
+ * ## 真源:为什么三件都只接 env
11
+ *
12
+ * design/170 §1 的三层分工表里,center 那一列对 B/C/D 写的都是「**新建**」——中心侧的档位真源 / 策略
13
+ * 真源 / 保留期真源**至今不存在**。接一条 center 腿等于造一个恒缺席的假面(件A 的 org 目录能接 center,
14
+ * 是因为那一面真被建出来了)。所以本装配点的供数只有部署配置面,并且这**恰好**是 design/170 §4.3 对
15
+ * 「谁能解锁」的裁定:锁是**配置期物**,铸锁真源只有 center governance 域与部署 env,二者都是 operator
16
+ * 控制的配置面,自带各自既有鉴权 —— 「谁能解锁」≡「谁能改部署配置」,不引入新鉴权面。
17
+ *
18
+ * ## 件B 的窄化:档位是**部署级**,不是 per-principal
19
+ *
20
+ * core 的座席签名按 principal 取值,本仓的 resolver 对每个 principal 返回同一份部署档位。这是诚实的窄
21
+ * 实现:per-principal 档位需要 center 的下发面(§3.2 R5「与 caps 同一次 fetch」),那一面尚未建。
22
+ * 座席在场即恒生效,不存在「某些 principal 悄悄没档位」的分岔。
23
+ *
24
+ * ## 件B 的**不采纳**:多租户缺解析器不拒启(显式定界)
25
+ *
26
+ * §3.4 建议「部署自称多租户却未配 B 的解析器 ⇒ 拒启动」。**本仓不采纳**,理由是三问里的第二问:
27
+ * 谁被伤到 —— 今天每一个 `REQUIRE_PRINCIPAL=true` 的部署都没有这一格,采纳即是让它们**全部**在下一次
28
+ * 升级时起不来,而它们并没有任何东西变得更不安全(B 缺席 = 无合规限制 = 与今天逐字相同的行为)。
29
+ * 补偿是这条 env 旋钮本身:要档位的部署显式写下它。将来 center 真有了档位下发面,「多租户必须能解析出
30
+ * 档位」才是一条有对象可指的判据,那时再立。
31
+ *
32
+ * ## 件C 的两条**装配相容性**判据(本仓特有,见 {@link assertLockAssemblable})
33
+ *
34
+ * core 的锁按「spec 是否占了这个字段」判,而本仓是**部署自己**在铸那两个 spec 字段:
35
+ * · `spec.toolPolicy` —— 治理拍(`applyRuntimeGovernance`)在**任何**客户端表态下都会铸出它(敏感路径
36
+ * 守卫集默认非空);于是 `toolPolicy` 锁会让 core 的 preflight **整拒每一条腿**。
37
+ * · `spec.mcp` —— 部署自带的 MCP 基线(场景/center 下发)同样经 `spec.mcp` 进 core;部署配了 MCP 又锁
38
+ * `mcp`,同样是每条腿必拒。
39
+ * 两者都在**启动期**拒(安全控件不得半开 §3.4 C 行),而不是让运维在每条任务腿上各撞一次
40
+ * `config.locked_key` 去猜。
41
+ *
42
+ * ## 件D 的边界(成文,别读成「数据会被删」)
43
+ *
44
+ * 本仓**没有任何** store 声明 `retention:"managed"`(亲验:session/checkpoint/tool-result 三族 SQL 双生
45
+ * 与 local 形都没有这一位),托管留存的执行面(调度/重试/副本协调/逐行审计)按 §5.2 是「server 半场四件
46
+ * 皆新建」,不在本批。所以本装配点对件D 只做两件事:①把策略递给 core 的启动期校验
47
+ * (`assertRetentionCapability` —— 锁着 + 店不能删 ⇒ 拒启,「策略锁着、数据永存」不许发生);②策略在场
48
+ * 而没有任何 managed 店时打一条响亮 warn。**不**假装有执行面。
49
+ */
50
+ import { assertRetentionCapability, resolveComplianceDenies, resolveLockedKeys } from "@sema-agent/core";
51
+ /**
52
+ * 件C 的装配相容性判据(纯,可单测)——见文件头「两条判据」。
53
+ *
54
+ * 🔴 失败方向朝**拒启**:一把在本部署上必然把每条腿都拒掉的锁,不是「配得比较严」,是配错了;继续启动
55
+ * 只会让服务对每一个请求说 `config.locked_key`,而运维手里没有任何线索指向那把锁。
56
+ */
57
+ export function assertLockAssemblable(input) {
58
+ if (input.lockedKeys.has("toolPolicy")) {
59
+ throw new Error('LOCKED_CONFIG_KEYS contains "toolPolicy", which this server cannot arm: it COMPOSES the effective tool policy itself on ' +
60
+ "every leg (deployment approval baseline + the tighten-only governance pass over the sensitive-path guard set), so " +
61
+ "`TaskSpec.toolPolicy` is always present at the engine's prepare door and the lock would refuse EVERY task with " +
62
+ "config.locked_key. The policy is already deployment-owned and no request can loosen it (the fold is deny-wins), so " +
63
+ "this lock has nothing to guard here — remove it from LOCKED_CONFIG_KEYS.");
64
+ }
65
+ if (input.lockedKeys.has("mcp") && input.deploymentMcpConfigured) {
66
+ throw new Error('LOCKED_CONFIG_KEYS contains "mcp" while this deployment configures MCP servers of its own (scenario/center MCP): the ' +
67
+ "deployment's servers ride the SAME `TaskSpec.mcp` field the lock guards (the engine has no deployment-level MCP seat), " +
68
+ "so every task would be refused at prepare with config.locked_key. Either drop the `mcp` lock, or remove the configured " +
69
+ "MCP servers — a locked deployment mounts no MCP at all.");
70
+ }
71
+ }
72
+ /**
73
+ * 治理面的**装配相容性**总判据(codex 对抗复审 R2-F2 起扩面)——锁与合规档位在这条轴上是同一件事:
74
+ * 两者都可能把「本部署自己的装配」判成 core 门口的违规,而那不是「配得更严」,是**这台从此每条腿必失败**。
75
+ *
76
+ * 合规半场的判据来自亲核 core 的 prepare 门(`prepare-task` 的 compliance 段):被否决的能力若**已在
77
+ * spec 上**,core 是**整拒 prepare**(`config.compliance_denied`),不是「不挂载」。于是:
78
+ * · `mcp_servers` 被禁 ∧ 部署自带 MCP 基线 ⇒ 每条腿的 `spec.mcp` 都在 ⇒ 必拒;
79
+ * · `web_fetch` 被禁 ∧ 花名册挂了 WebFetch ⇒ 必拒。⚠️ `hipaa` 的**内建 floor 就含 `web_fetch`**,而本仓
80
+ * 的单用户花名册**恒挂** WebFetch(`capabilities/scenarios.ts` 的 roster 过滤只在多租户下摘它)——
81
+ * 也就是说「单用户 + hipaa」在本仓是一个每条腿必失败的组合。这条判据的全部价值就在这儿:让它在
82
+ * 启动期说出来,而不是让运维看着一台"启动成功、任务全失败"的 worker 猜。
83
+ * · `workflows` 被禁只在 `spec.selfOrchestration === true` 的腿上拒(per-task,不是部署恒真)⇒ 不在本
84
+ * 判据内,由那条腿自己响亮拒;`org_memory_mount` 走件A 的准入面。**成文定界,不留白。**
85
+ */
86
+ export function assertGovernanceAssemblable(input) {
87
+ assertLockAssemblable({ lockedKeys: input.lockedKeys, deploymentMcpConfigured: input.deploymentMcpConfigured });
88
+ if (input.complianceDenies.has("mcp_servers") && input.deploymentMcpConfigured) {
89
+ throw new Error('the compliance posture denies "mcp_servers" while this deployment configures MCP servers of its own: the engine REFUSES ' +
90
+ "(not silently narrows) every prepare whose TaskSpec.mcp is non-empty, so every task would fail with config.compliance_denied. " +
91
+ "Remove the configured MCP servers, or drop mcp_servers from the posture.");
92
+ }
93
+ if (input.complianceDenies.has("web_fetch") && input.webFetchMounted) {
94
+ throw new Error('the compliance posture denies "web_fetch" while this deployment mounts the WebFetch tool: the engine REFUSES every prepare ' +
95
+ "whose roster carries it, so every task on the default roster would fail with config.compliance_denied. This server drops " +
96
+ "WebFetch from the roster only on a MULTI-TENANT deployment (REQUIRE_PRINCIPAL=true) — a single-user deployment cannot " +
97
+ "currently honor a web_fetch denial. Run the posture multi-tenant, or drop the profile/deny.");
98
+ }
99
+ }
100
+ /**
101
+ * 装配三座席。**拒启口即装配口**:相容性判据与 core 的留存能力校验都在这里跑,调用方不必记得再调一次
102
+ * (件A 的 C12 探测同姿势)。
103
+ */
104
+ export function createGovernanceSeams(ctx) {
105
+ const { config, logger, retentionStores } = ctx;
106
+ // 件C:锁集。config 层已用同一只 `resolveLockedKeys` 验过一遍;这里再验是因为 config 也可能由
107
+ // sema-registry 侧写入(env 不是唯一写点),而「安全控件不得半开」这条对每个写点都成立。
108
+ const lockedKeys = resolveLockedKeys(config.lockedConfigKeys !== undefined ? { keys: config.lockedConfigKeys } : undefined);
109
+ // 件B:合规档位。`resolveComplianceDenies` 在装配期跑一次 —— 它是 core 的 fail-loud 解析口,同时给出
110
+ // 这台部署真正在禁哪些能力(启动行的内容 + 下面那道相容性判据的输入)。resolver 本身返回**冻结的部署
111
+ // 档位**,不做任何 per-call 工作。
112
+ const posture = config.compliancePosture;
113
+ const complianceDenies = posture !== undefined ? resolveComplianceDenies(posture) : new Set();
114
+ // `mcpServers` 读的是**已应用 center 配置之后**的值(本装配点排在 config-center 段之后)。中心侧后来
115
+ // 加了 MCP 服务器怎么办:那一族是 baked-at-boot 面,变更会翻 `restartRequired` 让编排器滚动重启 ——
116
+ // 重启就撞上这道门。所以这里不需要第二条热路径判据。
117
+ // `webFetchMounted` 的判据与花名册那处的过滤**同源**(`capabilities/scenarios.ts`:
118
+ // `.filter((t) => t.name !== "WebFetch" || deps.requirePrincipal !== true)`)—— 多租户摘、单用户挂。
119
+ assertGovernanceAssemblable({
120
+ lockedKeys,
121
+ complianceDenies,
122
+ deploymentMcpConfigured: (config.mcpServers?.length ?? 0) > 0,
123
+ webFetchMounted: config.requirePrincipal !== true,
124
+ });
125
+ const lockedConfig = config.lockedConfigKeys !== undefined && config.lockedConfigKeys.length > 0 ? { keys: config.lockedConfigKeys } : undefined;
126
+ if (lockedConfig)
127
+ logger.info("locked_config_enabled", { keys: [...lockedKeys].sort() });
128
+ let compliancePostureResolver;
129
+ if (posture !== undefined) {
130
+ logger.info("compliance_posture_enabled", { profile: posture.profile, denies: [...complianceDenies].sort() });
131
+ compliancePostureResolver = () => posture;
132
+ }
133
+ // 件D:留存期。先跑 core 的启动期校验(策略值本身 + 锁着时的店能力),再补一条本仓特有的诚实 warn。
134
+ const retentionPolicy = config.retentionPolicy;
135
+ assertRetentionCapability({ policy: retentionPolicy, locked: lockedKeys.has("retentionPolicy"), stores: retentionStores });
136
+ if (retentionPolicy !== undefined) {
137
+ // 声明位的读法与 core 的校验口**同一个结构型**(`RetentionDeclaring`)——不自造一个平行的形状,
138
+ // 否则「core 认为这店 managed / 我方认为不是」这种分歧会静默存在。
139
+ const managed = retentionStores.filter((s) => s.store?.retention === "managed");
140
+ if (managed.length === 0) {
141
+ logger.warn("retention_policy_not_executed", {
142
+ maxAgeDays: retentionPolicy.maxAgeDays,
143
+ note: "RETENTION_MAX_AGE_DAYS is configured but NO wired store declares retention:\"managed\", and this build ships no " +
144
+ "retention scheduler — the policy is a DECLARATION only; nothing deletes data on its account. It is relayed to the " +
145
+ "engine for the startup capability check and will become effective when a managed-retention store + scheduler land.",
146
+ });
147
+ }
148
+ }
149
+ return { compliancePostureResolver, lockedConfig, retentionPolicy, lockedKeys };
150
+ }
151
+ //# sourceMappingURL=governance-seams.js.map
@@ -22,6 +22,9 @@ export interface LeaderCtx {
22
22
  toolResultStore: ToolResultStoreFull | undefined;
23
23
  sessionStore: ReturnType<StoreBackend["session"]>;
24
24
  checkpointStore: CheckpointStoreFull | undefined;
25
+ /** design/170 件B/C/D(#252,codex R2-F1):部署治理三座席 —— leader 车道的每一只 Runner 都要带上它,
26
+ * 否则开了 leader 端点的部署就有一条在部署治理**之外**的执行面(合规否决/锁都够不着)。 */
27
+ governanceSeams: import("./governance-seams.js").GovernanceSeams;
25
28
  }
26
29
  /**
27
30
  * 鲁棒性批5 A6(2026-08-05):k8s 腿要求 MinIO 三件套(ENDPOINT/ACCESS_KEY/SECRET_KEY)全在——半开(一件缺)
@@ -20,7 +20,7 @@ export function leaderK8sMinioGap(leaderEnabled, leaderProvider, hasS3) {
20
20
  ].filter((v) => typeof v === "string");
21
21
  }
22
22
  export function createLeaderFace(ctx) {
23
- const { config, logger, brain, pricing, executionEnvFactory, toolResultStore, sessionStore, checkpointStore } = ctx;
23
+ const { config, logger, brain, pricing, executionEnvFactory, toolResultStore, sessionStore, checkpointStore, governanceSeams } = ctx;
24
24
  // v2 leader endpoint (design/50 + design/68): wire when LEADER_ENABLED + an isolated remote-exec backend
25
25
  // (E2B or k8s/Kata — SSH/ADB are single-worker real-system backends, not fan-out targets). Default off →
26
26
  // zero prod impact. 🔴 a real run also needs `git` on the host PATH + a durable remote with write creds.
@@ -50,6 +50,12 @@ export function createLeaderFace(ctx) {
50
50
  (leaderProvider === "host" && executionEnvFactory))
51
51
  ? createLeaderEndpoint(createLeaderRunner({
52
52
  brain, models: config.models, roles: config.roles, pricing, logger,
53
+ // design/170 件B/C/D:主车道装配出的**同一份**治理座席(单一属主,leader 不另判)。
54
+ governance: {
55
+ compliancePostureResolver: governanceSeams.compliancePostureResolver,
56
+ lockedConfig: governanceSeams.lockedConfig,
57
+ retentionPolicy: governanceSeams.retentionPolicy,
58
+ },
53
59
  fanoutEnabled: config.leaderFanoutEnabled,
54
60
  // Route the single-vs-fanout classification on the cheap model (deepseek-v4-flash): the heavy
55
61
  // reasoning worker model returns empty ~2/3 of the time on the route prompt → silent collapse to
@@ -72,6 +72,9 @@ export interface ResolveSpecCtx {
72
72
  imageIndex: ReturnType<NonNullable<StoreBackend["imageIndex"]>> | undefined;
73
73
  perTaskImage: PerTaskImageRegistry;
74
74
  sessionEnvSelection: SessionEnvironmentSelection;
75
+ /** design/170 件C:本部署已校验的锁集(boot/governance-seams.ts 的产物,与 `RunnerDeps.lockedConfig`
76
+ * 同源一次解析)。空集 = 无锁 = 现行为。本域只用它做**同步拒面**(§4.3),不做第二次裁决。 */
77
+ lockedKeys: ReadonlySet<import("@sema-agent/core").LockedKey>;
75
78
  }
76
79
  export declare function createResolveSpec(ctx: ResolveSpecCtx): ServiceDeps["resolveSpec"];
77
80
  export {};