@sema-agent/server 7.4.0 → 7.5.0

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 (133) hide show
  1. package/USAGE.md +43 -0
  2. package/dist/approval-card.d.ts +15 -3
  3. package/dist/approval-card.js +41 -7
  4. package/dist/approval-reconciler.d.ts +108 -11
  5. package/dist/approval-reconciler.js +146 -19
  6. package/dist/boot/coordinators.js +10 -2
  7. package/dist/boot/org-memory.d.ts +6 -0
  8. package/dist/boot/org-memory.js +1 -1
  9. package/dist/boot/reapers.d.ts +2 -0
  10. package/dist/boot/reapers.js +11 -4
  11. package/dist/boot/resolve-spec.d.ts +3 -2
  12. package/dist/boot/resolve-spec.js +132 -32
  13. package/dist/boot/runner-deps.d.ts +23 -1
  14. package/dist/boot/runner-deps.js +8 -11
  15. package/dist/boot/workflow-orchestration.d.ts +8 -3
  16. package/dist/boot/workflow-orchestration.js +23 -1
  17. package/dist/config-center/apply-effective.js +33 -10
  18. package/dist/config-types.d.ts +24 -8
  19. package/dist/config.d.ts +6 -1
  20. package/dist/config.js +56 -11
  21. package/dist/env-facts.d.ts +3 -1
  22. package/dist/env-facts.js +3 -1
  23. package/dist/fleet/fleet-bus.d.ts +6 -1
  24. package/dist/fleet/fleet-bus.js +25 -3
  25. package/dist/governance-ask-marks.d.ts +31 -0
  26. package/dist/governance-ask-marks.js +122 -0
  27. package/dist/hooks/hook-runner.d.ts +28 -0
  28. package/dist/hooks/hook-runner.js +149 -25
  29. package/dist/http/routes/diagnostics.js +10 -5
  30. package/dist/http/routes/memory-policy.d.ts +2 -1
  31. package/dist/http/routes/memory-policy.js +77 -13
  32. package/dist/http/routes/runs.js +1 -1
  33. package/dist/http/routes/tasks.js +59 -22
  34. package/dist/http/server.d.ts +5 -0
  35. package/dist/http/server.js +23 -12
  36. package/dist/http/wire-types.d.ts +7 -2
  37. package/dist/main.js +17 -5
  38. package/dist/observability/fail-open.d.ts +13 -2
  39. package/dist/observability/fail-open.js +15 -4
  40. package/dist/observability/prompt-manifest.d.ts +5 -1
  41. package/dist/orchestration/workflow-notify-journal.d.ts +57 -1
  42. package/dist/orchestration/workflow-notify-journal.js +137 -32
  43. package/dist/parked-decide.js +9 -4
  44. package/dist/plugins/approval-ask-store-memory.d.ts +2 -2
  45. package/dist/plugins/approval-ask-store-memory.js +3 -2
  46. package/dist/plugins/approval-ask-store-sql.d.ts +27 -5
  47. package/dist/plugins/approval-ask-store-sql.js +9 -2
  48. package/dist/plugins/background-shell-support.d.ts +1 -1
  49. package/dist/plugins/background-shell-support.js +2 -2
  50. package/dist/plugins/checkpoint-store-sql.d.ts +62 -6
  51. package/dist/plugins/checkpoint-store-sql.js +71 -11
  52. package/dist/plugins/local-checkpoint-store.d.ts +20 -1
  53. package/dist/plugins/local-checkpoint-store.js +19 -0
  54. package/dist/plugins/mailbox-store-sql.d.ts +4 -10
  55. package/dist/plugins/mailbox-store-sql.js +57 -4
  56. package/dist/runs.d.ts +8 -0
  57. package/dist/runs.js +15 -2
  58. package/dist/runtime-governance.d.ts +18 -0
  59. package/dist/runtime-governance.js +90 -3
  60. package/dist/task-settings.d.ts +3 -9
  61. package/dist/task-settings.js +16 -13
  62. package/dist/tool-approval.d.ts +33 -6
  63. package/dist/tool-approval.js +80 -23
  64. package/dist/trace/core-keyset-guard.d.ts +17 -3
  65. package/package.json +3 -3
  66. package/dist/boot/lexical-path-env.d.ts +0 -10
  67. package/dist/boot/lexical-path-env.js +0 -88
  68. package/dist/capabilities/oa-tools.d.ts +0 -15
  69. package/dist/capabilities/oa-tools.js +0 -54
  70. package/dist/finance/cost-taxonomy.d.ts +0 -34
  71. package/dist/finance/cost-taxonomy.js +0 -26
  72. package/dist/plugins/approval-store-sql.d.ts +0 -116
  73. package/dist/plugins/approval-store-sql.js +0 -151
  74. package/dist/plugins/file-workflow-journal-store.d.ts +0 -12
  75. package/dist/plugins/file-workflow-journal-store.js +0 -12
  76. package/dist/plugins/pg-approval-store.d.ts +0 -9
  77. package/dist/plugins/pg-approval-store.js +0 -9
  78. package/dist/plugins/pg-breaker-state.d.ts +0 -8
  79. package/dist/plugins/pg-breaker-state.js +0 -8
  80. package/dist/plugins/pg-checkpoint-store.d.ts +0 -10
  81. package/dist/plugins/pg-checkpoint-store.js +0 -10
  82. package/dist/plugins/pg-file-snapshot-store.d.ts +0 -8
  83. package/dist/plugins/pg-file-snapshot-store.js +0 -8
  84. package/dist/plugins/pg-image-bake.d.ts +0 -12
  85. package/dist/plugins/pg-image-bake.js +0 -11
  86. package/dist/plugins/pg-image-index.d.ts +0 -12
  87. package/dist/plugins/pg-image-index.js +0 -11
  88. package/dist/plugins/pg-outcome-ledger.d.ts +0 -12
  89. package/dist/plugins/pg-outcome-ledger.js +0 -11
  90. package/dist/plugins/pg-resume-anchor-store.d.ts +0 -7
  91. package/dist/plugins/pg-resume-anchor-store.js +0 -7
  92. package/dist/plugins/pg-run-store.d.ts +0 -9
  93. package/dist/plugins/pg-run-store.js +0 -9
  94. package/dist/plugins/pg-session-policy-store.d.ts +0 -7
  95. package/dist/plugins/pg-session-policy-store.js +0 -7
  96. package/dist/plugins/pg-session-store.d.ts +0 -12
  97. package/dist/plugins/pg-session-store.js +0 -12
  98. package/dist/plugins/pg-tool-result-store.d.ts +0 -9
  99. package/dist/plugins/pg-tool-result-store.js +0 -9
  100. package/dist/plugins/pg-workflow-journal-store.d.ts +0 -9
  101. package/dist/plugins/pg-workflow-journal-store.js +0 -9
  102. package/dist/plugins/pg-workflow-run-store.d.ts +0 -9
  103. package/dist/plugins/pg-workflow-run-store.js +0 -9
  104. package/dist/plugins/tidb-approval-store.d.ts +0 -8
  105. package/dist/plugins/tidb-approval-store.js +0 -8
  106. package/dist/plugins/tidb-breaker-state.d.ts +0 -7
  107. package/dist/plugins/tidb-breaker-state.js +0 -7
  108. package/dist/plugins/tidb-checkpoint-store.d.ts +0 -9
  109. package/dist/plugins/tidb-checkpoint-store.js +0 -9
  110. package/dist/plugins/tidb-file-snapshot-store.d.ts +0 -8
  111. package/dist/plugins/tidb-file-snapshot-store.js +0 -8
  112. package/dist/plugins/tidb-image-bake.d.ts +0 -12
  113. package/dist/plugins/tidb-image-bake.js +0 -11
  114. package/dist/plugins/tidb-image-index.d.ts +0 -12
  115. package/dist/plugins/tidb-image-index.js +0 -11
  116. package/dist/plugins/tidb-outcome-ledger.d.ts +0 -12
  117. package/dist/plugins/tidb-outcome-ledger.js +0 -12
  118. package/dist/plugins/tidb-resume-anchor-store.d.ts +0 -7
  119. package/dist/plugins/tidb-resume-anchor-store.js +0 -7
  120. package/dist/plugins/tidb-run-store.d.ts +0 -10
  121. package/dist/plugins/tidb-run-store.js +0 -9
  122. package/dist/plugins/tidb-session-policy-store.d.ts +0 -7
  123. package/dist/plugins/tidb-session-policy-store.js +0 -7
  124. package/dist/plugins/tidb-tool-result-store.d.ts +0 -8
  125. package/dist/plugins/tidb-tool-result-store.js +0 -10
  126. package/dist/plugins/tidb-workflow-journal-store.d.ts +0 -9
  127. package/dist/plugins/tidb-workflow-journal-store.js +0 -9
  128. package/dist/plugins/tidb-workflow-run-store.d.ts +0 -10
  129. package/dist/plugins/tidb-workflow-run-store.js +0 -10
  130. package/dist/plugins/workflow-journal-limits.d.ts +0 -12
  131. package/dist/plugins/workflow-journal-limits.js +0 -12
  132. package/dist/sema-registry.d.ts +0 -41
  133. package/dist/sema-registry.js +0 -40
package/USAGE.md CHANGED
@@ -150,6 +150,26 @@ MODEL_CODE_ROLES=default,subagent # 不设=全中立;仅这些角色在「
150
150
  - 经 `RoleSpec.systemPrompt` 挂在**角色**上,非开发角色保持中立、全局默认 `DEFAULT_SYSTEM_PROMPT` 不变;任务自带 `systemPrompt`(或客户端注入)时仍优先。
151
151
  - **验证门**(core 1.44,opt-in):请求体带 `verify:true`(可选 `verifyRounds`,夹到 [1,5]、默认 2)→ 任务跑完后由**独立只读对抗 verifier**(`verifier` 角色,默认=主模型)证据强制地"试图 break 它",FAIL 则把 findings 注回同 session 续跑修复→重验,循环到 PASS 或轮数上限。结果带 `verification:{verdict,rounds,findings,evidence}`(`verdict` 看质量,`result`/`status` 仍是实现的)。**仅 `/v1/tasks`(同步)与 `/v1/runs`(异步)**——`/v1/tasks/stream` 不支持(多轮非单流,请求 verify 会 400)。verifier 工具默认 = 实现任务工具滤掉 `effect:"write"`(只读边界)。`/metrics` 加 `verifications_total{verdict}`。
152
152
  - **记忆(design/138 文件记忆引擎,2026-07-08 起唯一记忆面)**:core 注入式文件引擎——任务开始时 materialize 记忆目录(`MEMORY_ENGINE_DIR`,默认 `~/.ai-agent`),模型用**普通文件技能**读写记忆(CC `# Memory` 指令 + 派生索引;无 remember/recall 工具),任务边界 harvest 门(secret/cap 扫描)提交。单用户默认开,`MEMORY_ENGINE=off` 显式关;多租户恒关(文件基座无租户隔离,fail-closed)。旧 SQL 记忆面(`MEMORY_BACKEND`/`EMBEDDING_*`/`MEMORY_READ_LIMIT`/去重/向量检索、`GET/DELETE /v1/memory` 与 session memory 写 verb)已退役,数据不迁移——升级后对库跑一次 `scripts/drop-memory-tables.sql`。`body.memoryWrite:false` 仍是每请求只读开关(harvest 不提交)。
153
+ - **org 记忆准入(design/170 件A,7.0.0 起 BREAKING)**:`org:*` 记忆 scope 分**两个来源**——部署自证
154
+ (env `MEMORY_SCOPE` 的 org 形 + **单用户部署**的 `projects[].defaultScopes` org 键)直通;**多租户**
155
+ 部署里由调用方 `projectId` 选中的登记簿 org 键算 **request 来源**,必须拿到授权目录的逐 principal
156
+ 授予才准入,拿不到一律 fail-closed 拒(纯读泄露面:projectId 只过形状门不过授权)。目录源**三态单选,
157
+ 授权面不双源合并**:config-center 远程腿(per-principal `orgMemory` 段)> `MEMORY_ORG_DIRECTORY_JSON`
158
+ 静态表 > 缺席(request 来源的 org scope 恒拒)。同一个目录也是 `GET /v1/memory/export` /
159
+ `POST /v1/memory/sync/:scope` 的 `org:` 属主门真源(写面另需条目 `write:true`);拒绝形 = 终局
160
+ `memory.admission_denied`(403)/ 瞬时 `memory.admission_required`(503,带 `retryAfterSec`)。
161
+
162
+ | env | 缺省 | 说明 |
163
+ |---|---|---|
164
+ | `MEMORY_ORG_ADMISSION_MODE` | `enforce` | `enforce`=判决即结果;`audit`=**运维诊断位**——准入面判决照算、拒绝降为日志+metric,零行为变化(不整拒、不窄化写面);`/v1/memory/export`、`/v1/memory/sync/:scope` 的 `org:` 属主门在 audit 下**逐字保持收编前的 operator-only**(未验证的目录不得开数据面)。两模式都要求目录 client 在场 |
165
+ | `MEMORY_ORG_DIRECTORY_JSON` | 缺省 | 单机形静态授权表 `{"<principal>":{"org:acme":{"write":true}}}`。**启动期整表校验,坏表拒启动**。有 config-center 时被忽略(center 胜 + warn) |
166
+ | `MEMORY_ORG_GRANT_TTL_MS` | `60000`(`[1000, 3600000]`) | 授予/负结果的缓存 TTL = 「有界 LKG」:**新任务/新 resume 腿**的准入判决滞后 ≤ 此值;**在跑任务不受吊销影响**(判决点在 prepare 期) |
167
+ | `MEMORY_ORG_UNAVAILABLE_BACKOFF_MS` | `10000`(`[500, 600000]`) | 目录取不到之后的退避窗(只用于取数失败臂,不用于负结果) |
168
+
169
+ ⚠️ **拒启动**:多租户 + 记忆面点亮 + `projects[].defaultScopes` 里有 org 键 + 目录源缺席 ⇒ 启动报错
170
+ 并点名 projectId(该部署的每个此类请求都会在 prepare 期整拒,响亮拒启动比静默全拒服务诚实)。
171
+ ⚠️ **回滚脚枪**:回滚到「无 config-center」模板前先清掉残留的 `MEMORY_ORG_DIRECTORY_JSON` —— 否则
172
+ 它作为 operator 自证通道**复活旧授权**(详见 `docs/DEPLOY-PREREQS.md`)。
153
173
 
154
174
  **可选 — 模型级联 cascade(core 1.45,opt-in)**
155
175
  ```bash
@@ -408,6 +428,29 @@ curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
408
428
 
409
429
  > **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`。
410
430
  >
431
+ > **🔧 升级到 7.5.0(引擎 core 5.17.0)前:把待决审批排空。** 5.17.0 起,park 铸行按**后端能承载的
432
+ > 宽度**落——审批人看到的 args / 预览、盘上躺着的行、resume 真正执行的那份参数,以及运维在 `/decide`
433
+ > 上要回显的那个不透明 `boundInputHash`,都从同一份投影铸出。**本服务的两条 checkpoint 后端
434
+ > (SQL 双生 / 本地文件)都是 JSON 序列化**,已按 5.17.0 的新轴显式声明 `fidelity: "json"`,所以对
435
+ > **JSON 值域**(模型产出的 args 恒在此域内)这次折叠规则变更是**逐字节零变化**:同一份 args 在 7.4.0
436
+ > 和 7.5.0 上算出的 `boundInputHash` 相同,已经发出去的哈希不需要重新取。
437
+ > 需要动作的只有一种行:**升级前就已经 pending 的那些**——它们带的是旧版本铸的哈希与(可能已降级的)
438
+ > 参数,新引擎不会追认改写。**滚版前把它们批/否掉**(`GET /v1/approvals` 列出来,逐个 `/decide`),
439
+ > 或明确接受那批老行仍按旧语义结算。新铸的行不受影响。
440
+ > **同一次升级还要重建 mailbox 两表。** `mailbox_messages` 新增 `hop_chain` 列(引擎的 peer 消息守卫),
441
+ > 而建表语句是 `CREATE TABLE IF NOT EXISTS` —— 对已存在的旧表**一字不改**,升级后每一次 teammate 消息
442
+ > 投递都会报 unknown column 并失败。按本服务的 schema 契约(删库重建、不做增量迁移),滚版时
443
+ > **重建 `mailboxes` / `mailbox_messages` 两表**(或整库),基线见 `docs/schema/baseline-*.sql`。
444
+ > 该表是带 TTL 的短命投递队列而非账本,重建只丢排队中的 teammate 消息;介意就先让在飞的 peer 会话收敛。
445
+ >
446
+ > 顺带一提,新引擎会在铸点**直接拒绝 park**(点名后端、退回同步门)的只有两类值,而且都只可能由
447
+ > 部署侧的 hook / policy 改写进 args —— 模型自己给的参数永远是 JSON,碰不到任何一条:
448
+ > ① **拿不住的值**(函数、symbol、活句柄这些 `structuredClone` 复制不了的),以及 **`SharedArrayBuffer`**
449
+ > ——后者的理由不是「编不成 JSON」而是「克隆之后仍与原持有者共享同一块内存」,一行存下去别人还能改它,
450
+ > 所以在捕获性检查那一步就被拒;② **编不成 JSON 的值**(`BigInt`、循环引用)。
451
+ > 至于 `Date` / `Map` / 正则这类**能编码但会被 JSON 投影压扁**的值,不拒绝 —— 它们按投影后的形态入行,
452
+ > 若投影改变了值,引擎会拿投影后的那份**重新过一遍部署策略**再决定 park。
453
+ >
411
454
  > **parked 后台子代的待办分两个 scope 桶**(durable 审批面,core 1.389 起):父任务显式转发审批范围的常规 ask 落在该范围的 scope 下(可预算);无转发时无人值守拦下的敏感操作 ask 落在按 principal 派生的隔离 scope 下(带缺省 deadline、永不自动放行)。operator 全量列表天然两桶全见;**按 `?owner` 过滤时注意两桶可能不同名**,展示面要两个都查。
412
455
 
413
456
  ---
@@ -32,7 +32,11 @@ export declare const MAX_AGENT_NAME = 200;
32
32
  * `risk` 的三态形(设计稿 §14.1,core [2794] 回帖后定):`AskRequest.riskAxes?.{irreversible,egress}`
33
33
  * 是 **additive optional**,**缺席 = 引擎未判,不是「安全」**。所以两轴在这里是 `optional()`:
34
34
  * `true` / `false` / **缺席(未标注)** 三态各自可分,投影层**禁把缺席折算成 false** —— 那等于替引擎
35
- * 打包票。`requiresRealApproval` core 今天唯一在场的粗粒度安全类标记(必填,车2 已有真值来源)。
35
+ * 打包票。`requiresRealApproval` 是**粗粒度**安全类标记,两侧的在场契约**不同、别混**:core 侧是
36
+ * `AskRequest.requiresRealApproval?: boolean`(**可选、只在为真时带**,缺席 = 这不是一次安全类 ask,是
37
+ * 正常的否定形而不是坏形);本卡面这一格是**必填 boolean**,由车2 的入参归一化(`=== true`)而来。
38
+ * 它与两轴是两件事而不是新旧替代——原注写的「core 今天唯一在场的标记」是 `riskAxes` 上树之前的现势话,
39
+ * 自 core **5.14.0**(其 CHANGELOG 的 `AskRequest.riskAxes` additive 条)起两者并存。
36
40
  */
37
41
  export declare const ApprovalCardSchema: z.ZodObject<{
38
42
  toolName: z.ZodString;
@@ -45,6 +49,7 @@ export declare const ApprovalCardSchema: z.ZodObject<{
45
49
  egress: z.ZodOptional<z.ZodBoolean>;
46
50
  requiresRealApproval: z.ZodBoolean;
47
51
  }, z.core.$strict>;
52
+ governanceForced: z.ZodOptional<z.ZodLiteral<true>>;
48
53
  fromSubagent: z.ZodOptional<z.ZodLiteral<true>>;
49
54
  sourceTaskId: z.ZodOptional<z.ZodString>;
50
55
  sourceAgentName: z.ZodOptional<z.ZodString>;
@@ -75,6 +80,7 @@ export declare const ApprovalCardEnvelopeSchema: z.ZodObject<{
75
80
  egress: z.ZodOptional<z.ZodBoolean>;
76
81
  requiresRealApproval: z.ZodBoolean;
77
82
  }, z.core.$strict>;
83
+ governanceForced: z.ZodOptional<z.ZodLiteral<true>>;
78
84
  fromSubagent: z.ZodOptional<z.ZodLiteral<true>>;
79
85
  sourceTaskId: z.ZodOptional<z.ZodString>;
80
86
  sourceAgentName: z.ZodOptional<z.ZodString>;
@@ -107,6 +113,9 @@ export interface ApprovalCardSource {
107
113
  args?: unknown;
108
114
  argsOmitted?: boolean;
109
115
  toolCallId?: string;
116
+ /** [2942]/[2943]:治理来源标 —— 与 wire 帧**同一份素材**(`ToolApprovalFrame` 结构上满足本接口),
117
+ * 于是 live 帧 / `card_json` / 重放帧三面同源,不是三处各判一遍。 */
118
+ governanceForced?: true;
110
119
  fromSubagent?: true;
111
120
  sourceTaskId?: string;
112
121
  /** 已 redactSecrets。 */
@@ -121,8 +130,11 @@ export interface ApprovalCardSource {
121
130
  * design/172 §3.1 的**中性投影**(设计稿 §6.2)—— 写侧的唯一铸造点。
122
131
  *
123
132
  * `risk` 三态(§14.1):两轴 `optional`,`true`/`false`/**缺席(未标注)** 各自可分。`req` 是 core 交来的
124
- * `AskRequest`(可能带、也可能不带 `riskAxes`),按 `unknown` 窄读;`requiresRealApproval` 是 core 今天
125
- * 唯一在场的粗粒度安全类标记,缺席 = 这不是一次安全类 ask(core 的铸造点语义,不是我们的折算)。
133
+ * `AskRequest`(可能带、也可能不带 `riskAxes`),按 `unknown` 窄读;`requiresRealApproval` 走**独立入参**
134
+ * (车2 已归一化的 boolean),不从 `req` 里读。core 侧它是可选、只在为真时带,缺席 = 这不是一次安全类
135
+ * ask(core 的铸造点语义,不是我们的折算);卡面这一格恒在,`false` 就是那个否定形的如实投影。
136
+ * (原注写的「core 今天唯一在场的粗粒度标记」自 core 5.14.0 的 `riskAxes` 起过期,见 `ApprovalCardSchema`
137
+ * 头注。)
126
138
  */
127
139
  export declare function buildApprovalCard(source: ApprovalCardSource, req: unknown, requiresRealApproval: boolean): ApprovalCard;
128
140
  /** 落库信封的纯构造(写侧;读侧 = `ApprovalCardEnvelopeSchema.safeParse`,**同一个 schema**)。 */
@@ -35,7 +35,11 @@ const MAX_MESSAGE = 8192;
35
35
  * `risk` 的三态形(设计稿 §14.1,core [2794] 回帖后定):`AskRequest.riskAxes?.{irreversible,egress}`
36
36
  * 是 **additive optional**,**缺席 = 引擎未判,不是「安全」**。所以两轴在这里是 `optional()`:
37
37
  * `true` / `false` / **缺席(未标注)** 三态各自可分,投影层**禁把缺席折算成 false** —— 那等于替引擎
38
- * 打包票。`requiresRealApproval` core 今天唯一在场的粗粒度安全类标记(必填,车2 已有真值来源)。
38
+ * 打包票。`requiresRealApproval` 是**粗粒度**安全类标记,两侧的在场契约**不同、别混**:core 侧是
39
+ * `AskRequest.requiresRealApproval?: boolean`(**可选、只在为真时带**,缺席 = 这不是一次安全类 ask,是
40
+ * 正常的否定形而不是坏形);本卡面这一格是**必填 boolean**,由车2 的入参归一化(`=== true`)而来。
41
+ * 它与两轴是两件事而不是新旧替代——原注写的「core 今天唯一在场的标记」是 `riskAxes` 上树之前的现势话,
42
+ * 自 core **5.14.0**(其 CHANGELOG 的 `AskRequest.riskAxes` additive 条)起两者并存。
39
43
  */
40
44
  export const ApprovalCardSchema = z
41
45
  .object({
@@ -55,6 +59,25 @@ export const ApprovalCardSchema = z
55
59
  requiresRealApproval: z.boolean(),
56
60
  })
57
61
  .strict(),
62
+ /**
63
+ * [2942]/[2943] **ADDITIVE**:`true` ⇔ 这只 ask 的门来自运维治理层(语义、判定缝与「缺席 ≠ false」
64
+ * 的硬条款逐字见 `tool-approval.ts` 的 `ToolApprovalFrame.governanceForced` 与
65
+ * `governance-ask-marks.ts` 顶注)。放在**卡的顶层**而不是 `risk` 里:`risk` 讲的是引擎对这次操作的
66
+ * 风险判定(不可逆/出网/安全类),本键讲的是**门是谁下的**,两件事。
67
+ *
68
+ * ⚠️ **回滚窗的行为(codex 交叉复审 round1 [high],验真后按「真实但内生」收下)**。形版本闩
69
+ * (`schemaVersion`)**不动**:additive optional 键在 `.strict()` 下对**旧行**无碍(缺席合法),代价
70
+ * 全在**回滚方向** —— 一个降级回旧二进制的副本读到带本键的新行,`safeParse` 会失败,两条读面各自:
71
+ * · 重放腿(`buildReplayFrame`)⇒ 该行**跳过** + 一次 warn(§5.3),不炸流;
72
+ * · `askBroadcast` 幂等命中该行 ⇒ 走「持久行不可用」臂 ⇒ `"unavailable"` = **park 路由**
73
+ * (tool-approval.ts 的 `persisted-row-unusable`)。park 是本协议的 fail-safe 出口:人仍可经
74
+ * durable gate 补批 —— 不是「批准凭空消失」,更不是把它折算成 deny。
75
+ * 这个代价是**任何** additive 键在一个 `.strict()` schema 下的内生代价,不是本键独有:bump 到
76
+ * `schemaVersion: 2` 只会更糟(旧读面 `z.literal(1)` 直接全量拒收**所有**新行,连缺席本键的行都收不了)。
77
+ * ⇒ 处置 = 不改形版本;**发车条款**写进 CHANGELOG:整队滚前(不留长期混版窗),回滚窗内新卡降级为
78
+ * park(fail-safe),回滚窗结束即自愈。
79
+ */
80
+ governanceForced: z.literal(true).optional(),
58
81
  /** 委派出处(子代 ask 才在场;判别键 = `fromSubagent`,core RB-39②)。 */
59
82
  fromSubagent: z.literal(true).optional(),
60
83
  sourceTaskId: z.string().max(MAX_IDENT).optional(),
@@ -86,10 +109,16 @@ export const APPROVAL_CARD_SCHEMA_VERSION = 1;
86
109
  /**
87
110
  * core 的风险轴(`AskRequest.riskAxes`,[2829]/§14.1)的**边界窄读**。
88
111
  *
89
- * 🔴 为什么是 `safeParse` 而不是读 `req.riskAxes`:树上的 core d.ts(5.13.0)**还没有这个键**,
90
- * 而字段形已由 core 认领(5.14.0-pre)。窄读让**编译与行为解耦**——今天编译得过、缺席=未标注;
91
- * 终版到货后同一行代码自然点亮,不需要回来改一个字。裸 `as` 转型会在两个方向上都出错:既绕过了
92
- * 宪法 [2704] 的「边界必 schema」,也会在 core 真发出一个形状不同的键时静默把垃圾投上卡面。
112
+ * **已点亮**:`riskAxes` 现在既在类型面也在真码面(树上 core 5.16.0,`core/tool-policy.d.ts` 的
113
+ * `readonly riskAxes?: {…}`;package.json 的 floor 已抬到 `^5.16.0`)。窄读当初写下时的现势前提是
114
+ * 「树上 core d.ts(5.13.0)还没有这个键、字段形只由 core 认领」——该键自 core **5.14.0** 起就在
115
+ * (其 CHANGELOG `AskRequest.riskAxes` additive 条),那句现势话早已过期,**别再据它判断「core 还没
116
+ * 供值 ⇒ 卡面 risk 恒缺席」**。判在不在场以**装树的 d.ts 为准**,不要读注里的版本号
117
+ * (同族销账见 `tool-approval.ts` 的 `readBoundInputHash` 头注,#164)。
118
+ *
119
+ * 🔴 保留 `safeParse` 而不改成直读 `req.riskAxes` 的理由不变(它从来不只是等字段):窄读让**编译与行为
120
+ * 解耦**,缺席=未标注;裸 `as` 转型会在两个方向上都出错——既绕过宪法 [2704] 的「边界必 schema」,也会在
121
+ * core 发出一个形状不同的键(或加轴)时静默把垃圾投上卡面。
93
122
  *
94
123
  * 未知键被 zod 默认 strip(此处**故意不 `.strict()`**:core additive 加轴时不该让整只 ask 的卡面塌掉);
95
124
  * 形不合(如 `irreversible: "yes"`)⇒ 整个 safeParse 失败 ⇒ 按**缺席**处置 = 「未标注」,
@@ -115,8 +144,11 @@ function clip(s, max) {
115
144
  * design/172 §3.1 的**中性投影**(设计稿 §6.2)—— 写侧的唯一铸造点。
116
145
  *
117
146
  * `risk` 三态(§14.1):两轴 `optional`,`true`/`false`/**缺席(未标注)** 各自可分。`req` 是 core 交来的
118
- * `AskRequest`(可能带、也可能不带 `riskAxes`),按 `unknown` 窄读;`requiresRealApproval` 是 core 今天
119
- * 唯一在场的粗粒度安全类标记,缺席 = 这不是一次安全类 ask(core 的铸造点语义,不是我们的折算)。
147
+ * `AskRequest`(可能带、也可能不带 `riskAxes`),按 `unknown` 窄读;`requiresRealApproval` 走**独立入参**
148
+ * (车2 已归一化的 boolean),不从 `req` 里读。core 侧它是可选、只在为真时带,缺席 = 这不是一次安全类
149
+ * ask(core 的铸造点语义,不是我们的折算);卡面这一格恒在,`false` 就是那个否定形的如实投影。
150
+ * (原注写的「core 今天唯一在场的粗粒度标记」自 core 5.14.0 的 `riskAxes` 起过期,见 `ApprovalCardSchema`
151
+ * 头注。)
120
152
  */
121
153
  export function buildApprovalCard(source, req, requiresRealApproval) {
122
154
  const axes = RiskAxesEnvelopeSchema.safeParse(req);
@@ -134,6 +166,8 @@ export function buildApprovalCard(source, req, requiresRealApproval) {
134
166
  ...(riskAxes?.egress !== undefined ? { egress: riskAxes.egress } : {}),
135
167
  requiresRealApproval,
136
168
  },
169
+ // [2942]/[2943]:只在为真时投影(`=== true` 严判:非布尔真值不得把一张普通卡染成治理卡)。
170
+ ...(source.governanceForced === true ? { governanceForced: true } : {}),
137
171
  ...(source.fromSubagent === true ? { fromSubagent: true } : {}),
138
172
  ...(sourceTaskId !== undefined ? { sourceTaskId } : {}),
139
173
  ...(sourceAgentName !== undefined ? { sourceAgentName } : {}),
@@ -5,12 +5,26 @@
5
5
  * `PARKED | DENIED | VOID`,并在崩溃后补位那些没人打 expire 的孤儿 `STREAM_PENDING` 行。
6
6
  *
7
7
  * ── 判据表 v2(设计稿 §9 尾的五臂汇总,逐字落地;每臂注读口)────────────────────────────────────────
8
- * ① **identity ∧ hash 双等** ⇒ `bindBatch`(判别式返回;`ok:false` ⇒ 降级续判)
9
- * identity = (scope=owner, sessionId, toolCallId, 因果下界 `cp.createdAtMs ≥ ask.createdAtMs`)
10
- * ∧ `cp.boundInputHash === ask.boundInputHash`,**任一侧 hash 缺席 = 不命中**(§9 C2:同 session 内
11
- * `toolCallId` 会被网关重用,只靠 identity 会把旧 ask PARK 到别人的 resume 坐标上,而 `PARKED` 是
12
- * 不可回滚的终态)。读口 = `findCheckpointCandidatesForAsk`(§9 C4 窄谓词精确查,无分页假阴性);
13
- * `unparseable` 候选**视同不匹配**(单行读不出不许打断整段扫描,§8 C-6)。
8
+ * ① **身份三元组 ∧ hash 双等** ⇒ `bindBatch`(判别式返回;`ok:false` ⇒ 降级续判)
9
+ * 身份 = `sourceTaskId` 相等 ∧ `toolCallId` 相等 ∧ **因果下界**(不是等式)`cp.createdAtMs ≥
10
+ * ask.createdAtMs`;再 ∧ `cp.boundInputHash === ask.boundInputHash`。三维里只有前两维是等式,把时间
11
+ * 那一维读成等式会让合法 park 几乎命不中。逐字实现在 {@link classifyGateMatch};判据的**唯一
12
+ * 属主**是那个函数的头注,这里只列纲要,细则(祖先层 fold 否决、多候选取舍)不在此复述。
13
+ * 🔴 两处易错,写在这里免得下一个人照旧口径改码:
14
+ * · 承重的第一维是 **`sourceTaskId`**(#168 件1,黑板 [2897]③①)——`sessionId` **不进身份等式**
15
+ * (委派子代的 ask 落行记的是投递上下文的根会话,park 却发生在子代自己的 sessionId 上,只按
16
+ * session 等值会张冠李戴),它在这一层只是读口 `findCheckpointCandidatesForAsk` 的入参。
17
+ * ⚠️ 但别据此把它当无用键:{@link isAncestorFoldMint} 的祖先层否决判的正是
18
+ * `sourceTaskId !== sessionId` —— 那是内存里的承重用法,只是不属于身份等式;
19
+ * · hash **任一侧缺席一律不 bind**(硬相等不放宽,理由同下)。缺席的**归因**分两级,别写成一句:
20
+ * 身份先判 —— 同身份候选一条都没有且候选集非空 ⇒ `identity_miss`(有对家但不是这一只,或读不出);
21
+ * 只有在身份这一层没被判掉时,hash 缺席才落 `single_mint` 三形
22
+ * (`ask_only` / `checkpoint_only` / `neither`)。`single_mint` 是可观测分类、不是放宽的命中;
23
+ * 把「只有一侧铸过」这格结构事实混进 `identity_miss` 的噪声底,运维就读不出两者的区别。
24
+ * 两道等式都不许放宽的原因不变(§9 C2:同 session 内 `toolCallId` 会被网关重用,身份不严会把旧 ask
25
+ * PARK 到别人的 resume 坐标上,而 `PARKED` 是不可回滚的终态)。读口 = `findCheckpointCandidatesForAsk`
26
+ * (§9 C4 窄谓词精确查,无分页假阴性);`unparseable` 候选**视同不匹配**(单行读不出不许打断整段
27
+ * 扫描,§8 C-6)。
14
28
  * ② run 终局分臂(读口 `runStore.getRun`):`status ∈ {completed, failed, blocked}`(§8 A-1 词表修正 ——
15
29
  * `cancelled` 不是 run 状态,取消 = `failed` + `errorCode`)——
16
30
  * - `failed ∧ errorCode === "cancelled"`,或批行已 `ABORTED` ⇒ `VOID`(取消不是路由失败,§9 C3);
@@ -82,18 +96,82 @@ export interface ReconcileInput {
82
96
  allowBind?: boolean;
83
97
  }
84
98
  /**
85
- * 判据 1 的**硬谓词**(纯函数,§9 C2 + §8 D-2)。
99
+ * 判据 1 的一次**分类**结果(#168 件1;纯数据)。
86
100
  *
87
- * 返回选中的候选,或 `undefined` = 不命中。逐条:
101
+ * 为什么不只回「命中 / 不命中」:黑板 [2897]/[2898] 两帖把「两侧摘要不等」拆成了**四种成因**,处置各不
102
+ * 相同 —— 只有一种是真的「两个值不一样」,其余三种根本只有一侧(或零侧)铸过摘要。压成同一个
103
+ * `undefined` 会让运维看着一条「不匹配」去查一件从没发生过的事。
104
+ */
105
+ export type GateMatchOutcome =
106
+ /** 三元组身份 ∧ 摘要双等 —— 判据 1 命中。 */
107
+ {
108
+ kind: "match";
109
+ candidate: CheckpointAskCandidate;
110
+ }
111
+ /** 祖先冻结 approver 层 fold 中途的那一次铸造:其后还可能有 rewrite,两侧**本不该等**([2912]③)。 */
112
+ | {
113
+ kind: "ancestor_fold_mint";
114
+ }
115
+ /**
116
+ * 单铸路径 —— **不是** mismatch([2897]③②):
117
+ * - `ask_only`:纯 sync 腿(同轮结算,永无 checkpoint 行)/ 再审批链(2 次 ask 铸造、0 次 checkpoint);
118
+ * - `checkpoint_only`:durable-first 干净 args(ask 侧 0 次铸造,checkpoint 铸出);
119
+ * - `neither`:字符串模式 `onAsk` + durableApproval(两侧都没铸)。
120
+ */
121
+ | {
122
+ kind: "single_mint";
123
+ side: "ask_only" | "checkpoint_only" | "neither";
124
+ }
125
+ /**
126
+ * 三元组身份对得上、两侧摘要都在场却**不等** —— 唯一的真 mismatch。成因是**部署自伤**而非攻击
127
+ * ([2897]②:hook/policy 把自己仍持引用的活对象在两铸点之间改了,或每读返回新值的有状态 getter)。
128
+ * 处置 = 留痕不拒:计数 + 一条 warn,行照 ②③④⑤ 走(判据 1 不命中的既有 fail-safe 路径不变)。
129
+ */
130
+ | {
131
+ kind: "hash_mismatch";
132
+ candidates: number;
133
+ }
134
+ /** 三元组身份就对不上(不是摘要的事):别的 sourceTaskId / 别的 callId / 早于本 ask 的 park。 */
135
+ | {
136
+ kind: "identity_miss";
137
+ };
138
+ /**
139
+ * 这只 ask 是不是**祖先冻结 approver 层**在委派 fold 中途铸的那一份(#168 件1④,黑板 [2912]③)。
140
+ *
141
+ * 判别位是**产品自己发的**,不是外部约定:core 的 `withDelegationProvenance` 只包**子代自己那条缝**,
142
+ * 祖先层那次走未包装的原函数 ⇒ `AskRequest.delegation` 在不在,就是「这一份是哪一层发的」。落到行上,
143
+ * `delegation.parentToolCallId` 是必填字段,铸行时逐字透传进 `parent_tool_call_id` 列 ⇒ **列非 NULL
144
+ * ⇔ delegation 在场**(不需要新列,也不需要回 `req` 里再读一次)。
145
+ *
146
+ * 第二维 `sourceTaskId !== sessionId` 把「根腿」摘出去:根腿的 `sourceTaskId` 恒等于会话锚(core 给
147
+ * `AskRequest.sourceTaskId` 填的就是该腿 sessionId),它根本不在任何 fold 里,`parent_tool_call_id`
148
+ * 为 NULL 是它的常态而不是信号。
149
+ *
150
+ * 命中 ⇒ 退出硬相等(判据 1 结构上不命中,行落 ②③④⑤)。实测(test AI 围栏)checkpoint 存的摘要**逐字
151
+ * 等于子代自己那条缝**、只不等于祖先层那一次 —— 所以收窄到这一层,子代自己的缝照常参与。
152
+ */
153
+ export declare function isAncestorFoldMint(ask: AskRow): boolean;
154
+ /**
155
+ * 判据 1 的**硬谓词**(纯函数,§9 C2 + §8 D-2;#168 件1 起返回分类而不是布尔)。
156
+ *
157
+ * 逐条:
158
+ * - 祖先层 fold 中途铸点 ⇒ 直接退出(见 {@link isAncestorFoldMint});
88
159
  * - `unparseable` 候选直接出局(读不出 ⇒ 不确定 ⇒ 不命中);
89
- * - `boundCallId` 必须逐字等于 `ask.toolCallId`(读口已按它查,这里是纵深防御);
90
- * - **因果下界**:`cp.createdAtMs >= ask.createdAtMs`(park 不可能早于它要 park 的那次 ask);
91
- * - **hash 双等**:两侧都必须在场且相等 —— 任一侧缺席即不命中(禁「能取到时才比」的可选谓词)。
160
+ * - **身份三元组**:`sourceTaskId` **相等** `toolCallId` **相等** ∧ 时间维的**因果下界**
161
+ * `candidate.createdAtMs >= ask.createdAtMs`(⚠️ 第三维**不是等式** —— 把它读成等式会让合法的 park
162
+ * 几乎命不中,行随后落 ②③⑤ 被误判)—— 摘要**不是**身份([2897]③①:
163
+ * 同 args 的两次调用摘要天然相同,只靠摘要硬相等会把第二次投递并进第一次的票);任一维在候选侧
164
+ * 缺席(读不出的 blob 没有 `sourceTaskId`)即身份不成立;
165
+ * - **hash 双等**:两侧都必须在场且相等,任一侧缺席一律不 bind;禁「能取到时才比」的可选谓词。
166
+ * 归因分两级(顺序即代码顺序):身份这一层先判 —— 同身份候选为空**且**候选集非空 ⇒ `identity_miss`;
167
+ * 走到 hash 这一层才把缺席记成**单铸** `single_mint`(见 {@link GateMatchOutcome})。
92
168
  *
93
169
  * 多候选时的取舍:优先 `status === "pending"`(活着的那张 gate),否则取最早的一条(读口按
94
170
  * `created_at ASC` 返回)。两者都满足全部硬谓词,选谁都不会错配;取 pending 只是让 `PARKED` 行落到
95
171
  * 一个还能被 resume 的坐标上,对壳更有用。
96
172
  */
173
+ export declare function classifyGateMatch(ask: AskRow, candidates: readonly CheckpointAskCandidate[]): GateMatchOutcome;
174
+ /** {@link classifyGateMatch} 的布尔面(判据 1 命中即返回那条候选)。分类信息由调用方按需另取。 */
97
175
  export declare function selectGateCandidate(ask: AskRow, candidates: readonly CheckpointAskCandidate[]): CheckpointAskCandidate | undefined;
98
176
  /** 判据表 v2 的判定(纯函数;顺序 = 设计稿 §9 尾的五臂汇总,注见文件头)。 */
99
177
  export declare function decideReconcileAction(input: ReconcileInput): ReconcileAction;
@@ -119,6 +197,25 @@ export interface ReconcileStats {
119
197
  * 那正是运维需要当场看见的东西。恒零才是健康态,不是这条计数没用了。
120
198
  */
121
199
  unmatchableNoHash: number;
200
+ /**
201
+ * 🔴 判据 1 的**真** mismatch 行数(#168 件1③,黑板 [2897]②):三元组身份对得上、两侧摘要都在场却不等。
202
+ *
203
+ * 成因裁定 = **部署自伤,不是攻击**:core 已证在真正产生两次铸造的弧上摘要恒等,两条分歧路径都要求
204
+ * 部署自己交出活对象(hook/policy 返回己持引用后在两铸点之间突变;或每读返回新值的有状态 getter)。
205
+ * 所以处置是**留痕不拒**:计一笔 + 一条 warn,行照 ②③④⑤ 的既有 fail-safe 走 —— 判据 1 不命中本来
206
+ * 就不会产生终态 denial(约束②),这里不新增任何拒绝语义。
207
+ */
208
+ hashMismatch: number;
209
+ /**
210
+ * 单铸路径行数(#168 件1②)——**不计入** {@link hashMismatch}。三形分列:
211
+ * `askOnly` 纯 sync / 再审批链;`checkpointOnly` durable-first 干净 args;`neither` 两侧都没铸。
212
+ * 它们都是**结构上只有一侧(或零侧)有摘要**,把它们读成「比过了、不匹配」是把没发生的事记成异常。
213
+ */
214
+ singleMintAskOnly: number;
215
+ singleMintCheckpointOnly: number;
216
+ singleMintNeither: number;
217
+ /** 祖先冻结 approver 层 fold 中途铸点(#168 件1④):退出硬相等,不是 mismatch 也不是单铸。 */
218
+ ancestorFoldMint: number;
122
219
  parked: number;
123
220
  denied: number;
124
221
  voided: number;
@@ -42,24 +42,82 @@ function withDeadline(op, label, timeoutMs = RECONCILE_STORE_TIMEOUT_MS) {
42
42
  ]);
43
43
  }
44
44
  /**
45
- * 判据 1 的**硬谓词**(纯函数,§9 C2 + §8 D-2)。
45
+ * 这只 ask 是不是**祖先冻结 approver 层**在委派 fold 中途铸的那一份(#168 件1④,黑板 [2912]③)。
46
46
  *
47
- * 返回选中的候选,或 `undefined` = 不命中。逐条:
47
+ * 判别位是**产品自己发的**,不是外部约定:core `withDelegationProvenance` 只包**子代自己那条缝**,
48
+ * 祖先层那次走未包装的原函数 ⇒ `AskRequest.delegation` 在不在,就是「这一份是哪一层发的」。落到行上,
49
+ * `delegation.parentToolCallId` 是必填字段,铸行时逐字透传进 `parent_tool_call_id` 列 ⇒ **列非 NULL
50
+ * ⇔ delegation 在场**(不需要新列,也不需要回 `req` 里再读一次)。
51
+ *
52
+ * 第二维 `sourceTaskId !== sessionId` 把「根腿」摘出去:根腿的 `sourceTaskId` 恒等于会话锚(core 给
53
+ * `AskRequest.sourceTaskId` 填的就是该腿 sessionId),它根本不在任何 fold 里,`parent_tool_call_id`
54
+ * 为 NULL 是它的常态而不是信号。
55
+ *
56
+ * 命中 ⇒ 退出硬相等(判据 1 结构上不命中,行落 ②③④⑤)。实测(test AI 围栏)checkpoint 存的摘要**逐字
57
+ * 等于子代自己那条缝**、只不等于祖先层那一次 —— 所以收窄到这一层,子代自己的缝照常参与。
58
+ */
59
+ export function isAncestorFoldMint(ask) {
60
+ return ask.parentToolCallId === null && ask.sourceTaskId !== ask.sessionId;
61
+ }
62
+ /**
63
+ * 判据 1 的**硬谓词**(纯函数,§9 C2 + §8 D-2;#168 件1 起返回分类而不是布尔)。
64
+ *
65
+ * 逐条:
66
+ * - 祖先层 fold 中途铸点 ⇒ 直接退出(见 {@link isAncestorFoldMint});
48
67
  * - `unparseable` 候选直接出局(读不出 ⇒ 不确定 ⇒ 不命中);
49
- * - `boundCallId` 必须逐字等于 `ask.toolCallId`(读口已按它查,这里是纵深防御);
50
- * - **因果下界**:`cp.createdAtMs >= ask.createdAtMs`(park 不可能早于它要 park 的那次 ask);
51
- * - **hash 双等**:两侧都必须在场且相等 —— 任一侧缺席即不命中(禁「能取到时才比」的可选谓词)。
68
+ * - **身份三元组**:`sourceTaskId` **相等** `toolCallId` **相等** ∧ 时间维的**因果下界**
69
+ * `candidate.createdAtMs >= ask.createdAtMs`(⚠️ 第三维**不是等式** —— 把它读成等式会让合法的 park
70
+ * 几乎命不中,行随后落 ②③⑤ 被误判)—— 摘要**不是**身份([2897]③①:
71
+ * 同 args 的两次调用摘要天然相同,只靠摘要硬相等会把第二次投递并进第一次的票);任一维在候选侧
72
+ * 缺席(读不出的 blob 没有 `sourceTaskId`)即身份不成立;
73
+ * - **hash 双等**:两侧都必须在场且相等,任一侧缺席一律不 bind;禁「能取到时才比」的可选谓词。
74
+ * 归因分两级(顺序即代码顺序):身份这一层先判 —— 同身份候选为空**且**候选集非空 ⇒ `identity_miss`;
75
+ * 走到 hash 这一层才把缺席记成**单铸** `single_mint`(见 {@link GateMatchOutcome})。
52
76
  *
53
77
  * 多候选时的取舍:优先 `status === "pending"`(活着的那张 gate),否则取最早的一条(读口按
54
78
  * `created_at ASC` 返回)。两者都满足全部硬谓词,选谁都不会错配;取 pending 只是让 `PARKED` 行落到
55
79
  * 一个还能被 resume 的坐标上,对壳更有用。
56
80
  */
57
- export function selectGateCandidate(ask, candidates) {
81
+ export function classifyGateMatch(ask, candidates) {
82
+ const outcome = classifyGateMatchBeforeFoldVeto(ask, candidates);
83
+ // 🔴 祖先层否决**排在最后**、而且只否决已经成立的命中(纯减法)。放在最前面判会让这条谓词的每一次
84
+ // 假阳性(`sourceTaskId ≠ sessionId` 还有别的成因,例如 session-less 的 adhoc 腿)都白白关掉判据 1,
85
+ // 并把一条本该记成 `single_mint`/`identity_miss` 的行错记成「祖先层铸点」。放在这里,它最坏只是**不
86
+ // 命中**(与判据 1 的既有 fail-safe 同向),而计数从此只统计「本来会 bind、被这条裁定拦下」的行 ——
87
+ // 非零读数因此是真信号,不是噪声底。
88
+ if (outcome.kind === "match" && isAncestorFoldMint(ask))
89
+ return { kind: "ancestor_fold_mint" };
90
+ return outcome;
91
+ }
92
+ function classifyGateMatchBeforeFoldVeto(ask, candidates) {
93
+ // 身份三元组的前两维 + 因果下界(摘要不参与这一层——它是身份之外的**第二道**等式)。
94
+ const sameIdentity = candidates.filter((c) => c.unparseable !== true && c.sourceTaskId !== null && c.sourceTaskId === ask.sourceTaskId && c.boundCallId === ask.toolCallId && c.createdAtMs >= ask.createdAtMs);
58
95
  const askHash = ask.boundInputHash;
96
+ if (sameIdentity.length === 0) {
97
+ // 「同身份的 checkpoint 一条都没有」有两种成因,不能混:
98
+ // · 候选集**空** ⇒ 这条弧上根本没有对家,只有 ask 侧铸过(或两侧都没铸)= 单铸;
99
+ // · 候选集非空 ⇒ 有对家、只是不是这一只(别的 sourceTaskId/callId),或者读不出(`unparseable`,
100
+ // 「不确定」)。两者都**不是**「只有一侧铸过」这个事实陈述,一律记 `identity_miss`——
101
+ // 把一条读不出的坏行说成「对家从来不存在」是拿不确定冒充确定。
102
+ if (candidates.length > 0)
103
+ return { kind: "identity_miss" };
104
+ return { kind: "single_mint", side: askHash === null ? "neither" : "ask_only" };
105
+ }
106
+ const withHash = sameIdentity.filter((c) => c.boundInputHash !== null);
59
107
  if (askHash === null)
60
- return undefined; // hash 缺席 = 结构上永不满足判据 1(见 AskRow.boundInputHash 顶注)
61
- const matches = candidates.filter((c) => c.unparseable !== true && c.boundCallId === ask.toolCallId && c.createdAtMs >= ask.createdAtMs && c.boundInputHash !== null && c.boundInputHash === askHash);
62
- return matches.find((c) => c.status === "pending") ?? matches[0];
108
+ return { kind: "single_mint", side: withHash.length > 0 ? "checkpoint_only" : "neither" };
109
+ if (withHash.length === 0)
110
+ return { kind: "single_mint", side: "ask_only" }; // 同身份的 park 一条摘要都没有
111
+ const matches = withHash.filter((c) => c.boundInputHash === askHash);
112
+ const picked = matches.find((c) => c.status === "pending") ?? matches[0];
113
+ if (picked !== undefined)
114
+ return { kind: "match", candidate: picked };
115
+ return { kind: "hash_mismatch", candidates: withHash.length };
116
+ }
117
+ /** {@link classifyGateMatch} 的布尔面(判据 1 命中即返回那条候选)。分类信息由调用方按需另取。 */
118
+ export function selectGateCandidate(ask, candidates) {
119
+ const outcome = classifyGateMatch(ask, candidates);
120
+ return outcome.kind === "match" ? outcome.candidate : undefined;
63
121
  }
64
122
  /** 判据表 v2 的判定(纯函数;顺序 = 设计稿 §9 尾的五臂汇总,注见文件头)。 */
65
123
  export function decideReconcileAction(input) {
@@ -119,7 +177,8 @@ export function createApprovalReconciler(deps) {
119
177
  }
120
178
  }
121
179
  };
122
- /** 一条 `PARKING` 行的收敛(读事实 → 纯判定 → 一次 CAS)。返回本行落到哪一臂(供统计)。 */
180
+ /** 一条 `PARKING` 行的收敛(读事实 → 纯判定 → 一次 CAS)。返回本行落到哪一臂 + 判据 1 的分类(供统计;
181
+ * `match` 缺席 = 本轮压根没走到判据 1,与「走了但没命中」是两回事,不能记成同一格)。 */
123
182
  const reconcileOne = async (ask, nowMs) => {
124
183
  // ⓪ 批态**先读**(codex round3 R3-2):它是唯一一个能让本行「结构上再也不可能 bind」的事实,而且是
125
184
  // 一次主键读。批已 `ABORTED` 或已绑给**别人** ⇒ 判据 1 的 `bindBatch` 谓词(`ROUTING_UNBOUND`)
@@ -133,9 +192,42 @@ export function createApprovalReconciler(deps) {
133
192
  // 判据 1:窄谓词精确查(scope 必填 —— 租户门是读口自己的责任,§8 D-3)。因果下界直接下推成
134
193
  // `sinceMs`,SQL 侧就把早于本 ask 的 checkpoint 排掉了(`selectGateCandidate` 里还会再判一次,
135
194
  // 纵深防御)。
195
+ let gateMatch;
136
196
  if (checkpoints && !irreversibleBatch) {
197
+ // 🔴 **已知缺口,登记而不假称已解**(codex 交叉复审 2026-08-07 [high] 一,验真;**先存**,不是本批
198
+ // 引入):这里的 session 维传的是 `ask.sessionId` = **投递上下文**的会话(`tool-approval.ts` 铸行时
199
+ // 取 `primary.sessionId`),而委派子代那条腿的 checkpoint 落在**子代自己**的 sessionId 上
200
+ // (core 铸 `cp.sessionId = 该腿 sessionId`,同值也写进 `cp.sourceTaskId`)。⇒ 子代自己那条 approver
201
+ // 缝的 park 在 SQL 谓词这一层就被滤掉了,判据 1 对委派腿结构上够不着 —— 黑板 [2912]③「子代自己那条
202
+ // 缝可以参与硬相等」的那一半,在本仓当前接线下还落不了地(行改走 ②③④⑤,fail-safe)。
203
+ //
204
+ // 顺带说清本批**改善**了什么:三元组把 `cp.sourceTaskId === ask.sourceTaskId` 变成硬谓词之后,
205
+ // 一只子代 ask 再也不可能认领**宿主**腿的 park(同 session 查出来的候选,callId 撞车 + 同 args 摘要
206
+ // 相同就够了,而 `PARKED` 不可回滚)——那是本批之前真实存在的错配面。
207
+ //
208
+ // 修法(未做,需自带判据):把 session 维换成「铸 park 的那条腿的会话」。风险在于 `ask.sourceTaskId`
209
+ // 有 `?? origin.taskId` 兜底,core 万一不填就会把一个 taskId 当 session 去查(比今天更差),所以
210
+ // **必须**配一条用真 checkpoint 店、宿主/子代两个不同 sessionId 的端到端钉才动。开题上报。
137
211
  const candidates = await withDeadline(checkpoints.findCheckpointCandidatesForAsk(encodeCheckpointScope(ask.owner), ask.sessionId, ask.toolCallId, ask.createdAtMs), "findCheckpointCandidatesForAsk");
138
- const match = selectGateCandidate(ask, candidates);
212
+ gateMatch = classifyGateMatch(ask, candidates);
213
+ if (gateMatch.kind === "hash_mismatch") {
214
+ // 留痕不拒(见 ReconcileStats.hashMismatch 顶注):warn 说清成因方向,行继续按 ②③④⑤ 判。
215
+ try {
216
+ logger.warn("approval_reconcile_hash_mismatch", {
217
+ askId: ask.askId,
218
+ taskId: ask.taskId,
219
+ toolCallId: ask.toolCallId,
220
+ candidates: gateMatch.candidates,
221
+ reason: "ask and checkpoint minted the same bound input under the same (sourceTaskId, toolCallId) identity but the digests differ — " +
222
+ "the known causes are deployment-side (a hook/policy mutating an object it still holds a reference to between the two mints, " +
223
+ "or a stateful getter returning a fresh value per read), not tampering; this ask keeps its fail-safe route (no park, no denial)",
224
+ });
225
+ }
226
+ catch {
227
+ /* 可观测面绝不成为故障源 */
228
+ }
229
+ }
230
+ const match = gateMatch.kind === "match" ? gateMatch.candidate : undefined;
139
231
  if (match) {
140
232
  // 🔴 `bindBatch` 内部**一次**完成 `PARKING→PARKED` + gate 落行 + 兄弟连坐 VOID(§8 A-2:正文
141
233
  // 「先 transitionAsk 再 bindBatch」是死锁形,已作废)。只调它一次。
@@ -146,7 +238,7 @@ export function createApprovalReconciler(deps) {
146
238
  }), "bindBatch");
147
239
  if (res.ok) {
148
240
  revoke(ask, res.voidedSiblings, "superseded_by_park", nowMs);
149
- return "parked";
241
+ return { outcome: "parked", match: gateMatch };
150
242
  }
151
243
  // 判别式失败臂(§9 C3):拿到批的**真实**态 + 中选者,降级续判 —— `ABORTED` ⇒ VOID(abort 语义),
152
244
  // `ROUTING_BOUND` 且中选者是兄弟 ⇒ VOID(bind-once 落选者,R2-3),其余按 ②③④⑤。
@@ -162,13 +254,13 @@ export function createApprovalReconciler(deps) {
162
254
  case "bind": {
163
255
  // `allowBind: false` 下结构上不可达;留一条防御性 hold,绝不在这里第二次调 bindBatch。
164
256
  await withDeadline(askStore.deferReconcile(ask.askId, "PARKING", ask.version, nowMs), "deferReconcile");
165
- return "held";
257
+ return { outcome: "held", match: gateMatch };
166
258
  }
167
259
  case "deny": {
168
260
  // 🔴 全仓唯一的 `DENIED` 写点(grep 钉)。归因是判据 2 的**终局证据**,不是超时推断。
169
261
  // CAS 输(行在本轮判定期间被别人收走)= 正常,如实记成 held(没动过行),下轮重扫。
170
262
  const won = await withDeadline(askStore.transitionAsk(ask.askId, "PARKING", "DENIED", { deniedReason: action.reason, updatedAtMs: nowMs }), "transitionAsk(DENIED)");
171
- return won ? "denied" : "held";
263
+ return { outcome: won ? "denied" : "held", match: gateMatch };
172
264
  }
173
265
  case "void": {
174
266
  // 🔴 codex 交叉复审 C3(2026-08-06 真缺陷):**必须看 CAS 的返回值**。原先无条件记账 + 发撤卡帧
@@ -177,7 +269,7 @@ export function createApprovalReconciler(deps) {
177
269
  // 壳会清掉一张仍然有效、还在等 gate 的卡。输 ⇒ 什么都不做,下轮按新态重判。
178
270
  const won = await withDeadline(askStore.transitionAsk(ask.askId, "PARKING", "VOID", { deniedReason: action.reason, updatedAtMs: nowMs }), "transitionAsk(VOID)");
179
271
  if (!won)
180
- return "held";
272
+ return { outcome: "held", match: gateMatch };
181
273
  if (action.reason === VOID_REASONS.ORPHAN_TTL_EXCEEDED) {
182
274
  logger.warn("approval_ask_orphan_voided", { askId: ask.askId, taskId: ask.taskId, ageMs: nowMs - ask.createdAtMs, ttlMs: orphanTtlMs });
183
275
  }
@@ -190,19 +282,34 @@ export function createApprovalReconciler(deps) {
190
282
  // 落选者(R2-3)是**真的**被别人的 park 顶掉 ⇒ 用 `superseded_by_park`;其余单行 VOID 用 `"aborted"`
191
283
  // (两员闭集里唯一诚实的那个,见上注)。
192
284
  revoke(ask, [ask.askId], action.reason === VOID_REASONS.BATCH_BOUND_ELSEWHERE ? "superseded_by_park" : "aborted", nowMs);
193
- return "voided";
285
+ return { outcome: "voided", match: gateMatch };
194
286
  }
195
287
  case "hold": {
196
288
  // 队列轮转(§8 D-4 / §9 C5):推 `updated_at_ms` 排到队尾。CAS 输(版本已被别人推进)= 正常,
197
289
  // 本轮判定已过期,下轮重来。
198
290
  await withDeadline(askStore.deferReconcile(ask.askId, "PARKING", ask.version, nowMs), "deferReconcile");
199
- return "held";
291
+ return { outcome: "held", match: gateMatch };
200
292
  }
201
293
  }
202
294
  };
203
295
  return {
204
296
  async runOnce(nowMs) {
205
- const stats = { scanned: 0, unmatchableNoHash: 0, parked: 0, denied: 0, voided: 0, held: 0, failed: 0, budgetExhausted: 0, orphansExpired: 0 };
297
+ const stats = {
298
+ scanned: 0,
299
+ unmatchableNoHash: 0,
300
+ hashMismatch: 0,
301
+ singleMintAskOnly: 0,
302
+ singleMintCheckpointOnly: 0,
303
+ singleMintNeither: 0,
304
+ ancestorFoldMint: 0,
305
+ parked: 0,
306
+ denied: 0,
307
+ voided: 0,
308
+ held: 0,
309
+ failed: 0,
310
+ budgetExhausted: 0,
311
+ orphansExpired: 0,
312
+ };
206
313
  // ── 段一:PARKING 判据表 ────────────────────────────────────────────────────────────────
207
314
  const parking = await withDeadline(askStore.listByState("PARKING", batchLimit), "listByState(PARKING)");
208
315
  stats.scanned = parking.length;
@@ -221,7 +328,7 @@ export function createApprovalReconciler(deps) {
221
328
  if (ask.boundInputHash === null)
222
329
  stats.unmatchableNoHash += 1; // 判据 1 盲区(见字段顶注)
223
330
  try {
224
- const outcome = await reconcileOne(ask, nowMs);
331
+ const { outcome, match } = await reconcileOne(ask, nowMs);
225
332
  if (outcome === "parked")
226
333
  stats.parked += 1;
227
334
  else if (outcome === "denied")
@@ -230,6 +337,26 @@ export function createApprovalReconciler(deps) {
230
337
  stats.voided += 1;
231
338
  else
232
339
  stats.held += 1;
340
+ // 判据 1 的分类计数(#168 件1②③④)——`match` 缺席 = 本轮没走到判据 1(无 checkpoint 面 /
341
+ // 批已不可逆),那既不是 mismatch 也不是单铸,一格都不记。
342
+ switch (match?.kind) {
343
+ case "hash_mismatch":
344
+ stats.hashMismatch += 1;
345
+ break;
346
+ case "ancestor_fold_mint":
347
+ stats.ancestorFoldMint += 1;
348
+ break;
349
+ case "single_mint":
350
+ if (match.side === "ask_only")
351
+ stats.singleMintAskOnly += 1;
352
+ else if (match.side === "checkpoint_only")
353
+ stats.singleMintCheckpointOnly += 1;
354
+ else
355
+ stats.singleMintNeither += 1;
356
+ break;
357
+ default:
358
+ break; // match / identity_miss / 未走到判据 1:无计数格
359
+ }
233
360
  }
234
361
  catch (err) {
235
362
  // 🔴 per-row 隔离(邻居 bg-agent per-scope 先例):一条行的坏 blob / 一次瞬时错误不许饿死同批