@sema-agent/server 7.22.0 → 7.23.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.
package/README.md CHANGED
@@ -95,13 +95,13 @@ Requirements: Node ≥ 20 (npm path) and an OpenAI-compatible model gateway.
95
95
  ```bash
96
96
  # A) npm
97
97
  npm install @sema-agent/server
98
- MODEL_GATEWAY_BASEURL=https://api.deepseek.com MODEL_ID=deepseek-v4-pro \
98
+ MODEL_GATEWAY_BASEURL=https://api.deepseek.com MODEL_ID=deepseek-v4-flash \
99
99
  MODEL_API_KEY=<your-key> SERVICE_AUTH_TOKEN=<pick-one> \
100
100
  node node_modules/@sema-agent/server/dist/main.js # → :8090
101
101
 
102
102
  # B) container (zero local deps, anonymous pull)
103
103
  docker run -p 8090:8090 \
104
- -e MODEL_GATEWAY_BASEURL=https://api.deepseek.com -e MODEL_ID=deepseek-v4-pro \
104
+ -e MODEL_GATEWAY_BASEURL=https://api.deepseek.com -e MODEL_ID=deepseek-v4-flash \
105
105
  -e MODEL_API_KEY=<your-key> -e SERVICE_AUTH_TOKEN=<pick-one> \
106
106
  ghcr.io/sema-agent/sema-server:latest # or docker.io/claybobby/sema-server:latest
107
107
 
package/README.zh-CN.md CHANGED
@@ -89,13 +89,13 @@
89
89
  ```bash
90
90
  # A) npm
91
91
  npm install @sema-agent/server
92
- MODEL_GATEWAY_BASEURL=https://api.deepseek.com MODEL_ID=deepseek-v4-pro \
92
+ MODEL_GATEWAY_BASEURL=https://api.deepseek.com MODEL_ID=deepseek-v4-flash \
93
93
  MODEL_API_KEY=<你的-key> SERVICE_AUTH_TOKEN=<自定> \
94
94
  node node_modules/@sema-agent/server/dist/main.js # → :8090
95
95
 
96
96
  # B) 容器(零依赖,匿名可拉)
97
97
  docker run -p 8090:8090 \
98
- -e MODEL_GATEWAY_BASEURL=https://api.deepseek.com -e MODEL_ID=deepseek-v4-pro \
98
+ -e MODEL_GATEWAY_BASEURL=https://api.deepseek.com -e MODEL_ID=deepseek-v4-flash \
99
99
  -e MODEL_API_KEY=<你的-key> -e SERVICE_AUTH_TOKEN=<自定> \
100
100
  ghcr.io/sema-agent/sema-server:latest # 或 docker.io/claybobby/sema-server:latest
101
101
 
package/USAGE.md CHANGED
@@ -103,7 +103,7 @@ MODEL_DEGRADE_ON=rate_limit,breaker_open # 可选,反应式触发器子集;词
103
103
  # 不想要这两类触发就显式设回 rate_limit,breaker_open。拼错词 boot 拒启(不静默忽略)。
104
104
  ```
105
105
  - 反应式降级 brain 包在**最外层**(core council #8);fallback brain 用自己的凭据(decorator 清掉主模型的 per-call key 防泄漏给别的 provider)。**坑**:`MODEL_DEGRADE_TO` 最好别和被限流的是同一网关/账号,否则反应式切过去照样撞同一个 rate_limit。
106
- - **定价怎么设**:`model.cost` 来自 `MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE`(**USD per 1M tokens**,默认 0 = `costUsd` 读 0)。云模型(如 review-gw 的 deepseek-v4-pro)必须设,否则 spend 恒为 $0;本地自托管(qwen)留 0 即对(无 per-token 外部花费)。字段名是 `costUsd`,**非美元计价的网关要先折算**(如 DeepSeek 官方 CNY ÷ 汇率)。配置控制面管的模型走 `CenterModel.cost`(中心存价、不存 secret)。
106
+ - **定价怎么设**:`model.cost` 来自 `MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE`(**USD per 1M tokens**,默认 0 = `costUsd` 读 0)。云模型(如 review-gw 的 deepseek-v4-flash)必须设,否则 spend 恒为 $0;本地自托管(qwen)留 0 即对(无 per-token 外部花费)。字段名是 `costUsd`,**非美元计价的网关要先折算**(如 DeepSeek 官方 CNY ÷ 汇率)。配置控制面管的模型走 `CenterModel.cost`(中心存价、不存 secret)。
107
107
  - 成本计量**自动开**:从 config 的 `model.cost`(per-1M 绝对 USD)注入 `pricing`,core 算出权威的整数 `costMicroUsd`(避免浮点累计误差)。`/metrics` 新增:`model_cost_micro_usd_total{model}`(覆盖所有 brain 调用=主任务+异步+council 子任务的总花费)、`brain_first_token_ms`(网关 hang 早警)、`brain_call_latency_ms`、`tool_calls_total{name,ok}`、`budget_exceeded_total{code}`、`cost_quota_rejected_total`、`degraded_total{reason}`(1.40 降级)。
108
108
  - **近预算降级 vs 硬闸**:降级(`MODEL_DEGRADE_TO`,到 `atCostFraction` 切便宜模型)是**撑长**预算、任务仍完成(出口质量下降、发 `task.degraded` 事件可告警);硬闸(`maxCostUsd` 全额)仍在,切了便宜模型还超全额 → `budget.exceeded` 停。
109
109
  - **`METRICS_TOKEN`**(可选,只读):设了它,`GET /metrics`+`/metrics/summary` 接受**它或** `SERVICE_AUTH_TOKEN`。**全 fleet 设同一个值** → 管理端(sema-web,规划名 sema-admin)用**一个** token 拉所有 worker 的指标,**无需持有各 worker 的全权 `SERVICE_AUTH_TOKEN`**(不破坏 secret 边界)。即使泄露也只暴露指标(只读)。
@@ -131,7 +131,7 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 # 设了才开;周期
131
131
  OTEL_EXPORT_INTERVAL_MS=15000 OTEL_SERVICE_NAME=sema-server
132
132
  OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer xxx # 逗号分隔的 k=v(可选,如鉴权头)
133
133
  ```
134
- - **零依赖**:不引 OTel SDK(顾及内网镜像 + bun 单文件),直接按 OTLP/JSON 协议把指标注册表 POST 给 collector;Prometheus `/metrics` 不受影响、两者可并存。best-effort——collector 挂了只记日志、绝不影响服务。计数器→Sum(monotonic)、gauge→Gauge、histogram→Histogram(累积桶转成 OTLP 的 per-bucket + `+Inf` 溢出桶)。
134
+ - **零依赖**:不引 OTel SDK(顾及内网镜像 + bun 单文件),直接按 OTLP/JSON 协议把指标注册表 POST 给 collector;Prometheus `/metrics` 不受影响、两者可并存。best-effort——collector 挂了只记日志、绝不影响服务;⚠️ 这意味着**metrics 轴此前对导出失败零信号**(Prometheus-only 部署看不见 collector 持续宕机)——7.23.0 起每次导出失败计入 `fail_open_total{tag="server.otel.export-failed"}`,告警规则可直接盯这条。计数器→Sum(monotonic)、gauge→Gauge、histogram→Histogram(累积桶转成 OTLP 的 per-bucket + `+Inf` 溢出桶)。
135
135
 
136
136
  **可选 — 成本分层 + `@-model`(core 1.24 role map)**
137
137
  ```bash
@@ -148,7 +148,7 @@ MODEL_ID=<your-model-name> MODEL_CHEAP_ID=<your-cheap-model-name>
148
148
  MODEL_CODE_ROLES=default,subagent # 不设=全中立;仅这些角色在「任务没自带 systemPrompt」时跑编码提示
149
149
  ```
150
150
  - 经 `RoleSpec.systemPrompt` 挂在**角色**上,非开发角色保持中立、全局默认 `DEFAULT_SYSTEM_PROMPT` 不变;任务自带 `systemPrompt`(或客户端注入)时仍优先。
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}`。
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}`。⚠️ 同步 `/v1/tasks` 形**没有事件通道**(过程帧无处回放,只回终局 JSON)——带 `verify`/`cascade` 的 200 体自 7.23.0 起附可选 `notice` 键指路 `POST /v1/runs`(要过程可见性用异步形;[3979])。
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
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` 落下的删除照旧(延后,不销毁),并发的可写会话提交的变更算有事务背书。
@@ -445,7 +445,9 @@ SEMA_PARENT_PID=$$ # 壳把自己的 pid 传给它 spawn 出来的引擎
445
445
  错误文案直接给出该改成什么。所以不存在"两个名字同时设"的状态,也不存在"旧名还在悄悄生效"的状态。
446
446
 
447
447
  **`LSP_ENABLED` 一分为二**:它原来同时驱动两条腿,而且两腿缺省相反——沙箱腿缺省**关**(要烤好的
448
- `ai-agent-code-lsp` 模板),host 腿缺省**开**(只要 PATH 上有 language server,没有就优雅退回 grep/read)。
448
+ `ai-agent-code-lsp` 模板;⚠️ 该模板**只烤 TS/JS**——Python/Go 烤在 `dev-base`,dart/kotlin/java/swift
449
+ 烤在 `dev-mobile`,四模板物理独立,`E2B_TEMPLATE` 选错则未烤语言的 LSP **静默退化到 grep/read**;
450
+ 对照表见 ARCHITECTURE §7,真源=`src/lsp/e2b-bridge.ts`),host 腿缺省**开**(只要 PATH 上有 language server,没有就优雅退回 grep/read)。
449
451
  现在沙箱腿仍是 `LSP_ENABLED`(opt-in),host 腿归 `LSP_HOST_ENABLED`(opt-out)。
450
452
  🪦 `LSP_ENABLED=false`(拆分前唯一的 host 腿逃生舱)自 3.0.0 起是墓碑:拒启并指路 `LSP_HOST_ENABLED`。
451
453
  `LSP_ENABLED=true` 不受影响——那是沙箱腿自己的 opt-in,语义没变。
@@ -64,7 +64,7 @@ server:
64
64
  # 公网入口=Gcore LB listener 8091 → nodePort,LB 面不归 chart 管)
65
65
  serverV4pro:
66
66
  enabled: false
67
- modelId: deepseek-v4-pro
67
+ modelId: deepseek-v4-flash
68
68
  replicas: 1
69
69
  nodePort: 30891
70
70
  image: "" # 留空 = 跟 server.image 同镜像
@@ -21,6 +21,7 @@
21
21
  * 没有方法、没有捕获的行为。
22
22
  */
23
23
  import { z } from "zod";
24
+ import { type AskEvidenceAbsence } from "@sema-agent/core";
24
25
  import type { AskRow } from "./plugins/approval-ask-store-sql.js";
25
26
  /** 模型自由文本(`sourceAgentName` / `delegation.agentName`)的限长(设计稿 §6.2)。设计 §3.1 把
26
27
  * 「限长 + 脱敏」写成 **server 新增责任**(引擎无此层):spawning model 挑的名字是自由文本,
@@ -121,6 +122,36 @@ export type ProbeCauseProjection = z.infer<typeof ProbeCauseSchema>;
121
122
  * (截出来的是另一个机器码);`shown` 超限 ⇒ **截条目**,`total` 逐字保真。
122
123
  */
123
124
  export declare function readProbeCause(req: unknown): ProbeCauseProjection | undefined;
125
+ /**
126
+ * #263 件 G2 —— `AskRequest.ruleEvidence` 的形(帧 / `card_json` / 读面共用;窄读入口是
127
+ * {@link readRuleEvidence})。三对成员各 = 值**或**五词命名缺席(`AskEvidenceAbsence`),core 引擎
128
+ * 盖章的证据恰一在场;server 只判形转录,**永不**自铸缺席词(server 铸词 = 谎报「某一层没报」)。
129
+ */
130
+ export declare const RuleEvidenceSchema: z.ZodObject<{
131
+ orgRevision: z.ZodOptional<z.ZodNumber>;
132
+ orgRevisionAbsent: z.ZodOptional<z.ZodCustom<AskEvidenceAbsence, AskEvidenceAbsence>>;
133
+ orgRule: z.ZodOptional<z.ZodString>;
134
+ orgRuleAbsent: z.ZodOptional<z.ZodCustom<AskEvidenceAbsence, AskEvidenceAbsence>>;
135
+ personalRuleDots: z.ZodOptional<z.ZodArray<z.ZodObject<{
136
+ actor: z.ZodString;
137
+ counter: z.ZodNumber;
138
+ }, z.core.$strict>>>;
139
+ personalRuleDotsAbsent: z.ZodOptional<z.ZodCustom<AskEvidenceAbsence, AskEvidenceAbsence>>;
140
+ }, z.core.$strict>;
141
+ export type RuleEvidenceProjection = z.infer<typeof RuleEvidenceSchema>;
142
+ /**
143
+ * `AskRequest.ruleEvidence`(core 5.35.0 design/252 G-2)的**边界窄读**——活卡帧(`ToolApprovalFrame.
144
+ * ruleEvidence`)与 `card_json`(`buildApprovalCard`)的**唯一**铸造点,两面结构性同值(与
145
+ * {@link readProbeCause} 同款分工)。姿势逐条:
146
+ * · `safeParse`,形不合 ⇒ 按缺席处置(宪法 [2704]);未知键 strip(core additive 加键不塌卡)。
147
+ * · **恰一不变量**:一对成员(值 / 缺席词)**双在场** ⇒ 整只丢——那是矛盾证据(引擎盖章的恰一,
148
+ * 双在场只可能是第三方 producer 的坏行),半解上卡比缺席更坏。双缺席**容忍**(老 producer 行)。
149
+ * · `orgRule` 内容族(管理员写的规则文本)⇒ `redactSecrets` 后截 `MAX_RULE_TEXT_CHARS`,**先脱敏
150
+ * 再截一次算定**(`persistedRuleShadowed` R2-[medium] 教训:redact 会变长,两面各截会漂);空串丢整只。
151
+ * · `dots.actor` 身份串 ⇒ `redactSecrets`(对正常 id 恒等);超限**整只丢**(见上限顶注,身份不可截)。
152
+ * · `orgRevision` / 缺席词 verbatim(数值 / 闭集词,非内容)。
153
+ */
154
+ export declare function readRuleEvidence(req: unknown): RuleEvidenceProjection | undefined;
124
155
  /**
125
156
  * design/172 §3.1 的**中性投影**——呈卡面看到的全部内容,与 wire 面的既有 `ToolApprovalFrame` 解耦。
126
157
  *
@@ -165,6 +196,17 @@ export declare const ApprovalCardSchema: z.ZodObject<{
165
196
  total: z.ZodNumber;
166
197
  }, z.core.$strict>>;
167
198
  }, z.core.$strict>>;
199
+ ruleEvidence: z.ZodOptional<z.ZodObject<{
200
+ orgRevision: z.ZodOptional<z.ZodNumber>;
201
+ orgRevisionAbsent: z.ZodOptional<z.ZodCustom<AskEvidenceAbsence, AskEvidenceAbsence>>;
202
+ orgRule: z.ZodOptional<z.ZodString>;
203
+ orgRuleAbsent: z.ZodOptional<z.ZodCustom<AskEvidenceAbsence, AskEvidenceAbsence>>;
204
+ personalRuleDots: z.ZodOptional<z.ZodArray<z.ZodObject<{
205
+ actor: z.ZodString;
206
+ counter: z.ZodNumber;
207
+ }, z.core.$strict>>>;
208
+ personalRuleDotsAbsent: z.ZodOptional<z.ZodCustom<AskEvidenceAbsence, AskEvidenceAbsence>>;
209
+ }, z.core.$strict>>;
168
210
  fromSubagent: z.ZodOptional<z.ZodLiteral<true>>;
169
211
  sourceTaskId: z.ZodOptional<z.ZodString>;
170
212
  sourceAgentName: z.ZodOptional<z.ZodString>;
@@ -216,6 +258,17 @@ export declare const ApprovalCardEnvelopeSchema: z.ZodObject<{
216
258
  total: z.ZodNumber;
217
259
  }, z.core.$strict>>;
218
260
  }, z.core.$strict>>;
261
+ ruleEvidence: z.ZodOptional<z.ZodObject<{
262
+ orgRevision: z.ZodOptional<z.ZodNumber>;
263
+ orgRevisionAbsent: z.ZodOptional<z.ZodCustom<AskEvidenceAbsence, AskEvidenceAbsence>>;
264
+ orgRule: z.ZodOptional<z.ZodString>;
265
+ orgRuleAbsent: z.ZodOptional<z.ZodCustom<AskEvidenceAbsence, AskEvidenceAbsence>>;
266
+ personalRuleDots: z.ZodOptional<z.ZodArray<z.ZodObject<{
267
+ actor: z.ZodString;
268
+ counter: z.ZodNumber;
269
+ }, z.core.$strict>>>;
270
+ personalRuleDotsAbsent: z.ZodOptional<z.ZodCustom<AskEvidenceAbsence, AskEvidenceAbsence>>;
271
+ }, z.core.$strict>>;
219
272
  fromSubagent: z.ZodOptional<z.ZodLiteral<true>>;
220
273
  sourceTaskId: z.ZodOptional<z.ZodString>;
221
274
  sourceAgentName: z.ZodOptional<z.ZodString>;
@@ -21,7 +21,8 @@
21
21
  * 没有方法、没有捕获的行为。
22
22
  */
23
23
  import { z } from "zod";
24
- import { MAX_RULE_TEXT_CHARS } from "@sema-agent/core";
24
+ import { ASK_EVIDENCE_ABSENCE_VALUES, MAX_RULE_TEXT_CHARS } from "@sema-agent/core";
25
+ import { redactSecrets } from "./trace/redact.js";
25
26
  /** 模型自由文本(`sourceAgentName` / `delegation.agentName`)的限长(设计稿 §6.2)。设计 §3.1 把
26
27
  * 「限长 + 脱敏」写成 **server 新增责任**(引擎无此层):spawning model 挑的名字是自由文本,
27
28
  * 不能指望壳去截——一条 100KB 的 agentName 在 server 侧就该被拒,而不是变成一张撑爆呈卡面的卡。 */
@@ -165,6 +166,97 @@ export function readProbeCause(req) {
165
166
  ...(raw.further !== undefined ? { further: family(raw.further) } : {}),
166
167
  };
167
168
  }
169
+ /**
170
+ * #263 件 G2(core 5.35.0 design/252 G-2)—— `ruleEvidence` 的**防御性上限**两兄弟(信封非政策,
171
+ * 与 {@link MAX_PROBE_CAUSE_CODE} 家族同姿势:core 侧值来自引擎自己的治理层,没有导出上限可抄)。
172
+ * `dot.actor` 是身份串(规则属主 id),256 取 core `APPROVER_ATTRIBUTION_MAX_CHARS` 的同族语义;
173
+ * dots 条数的真实域 = 同一条规则文本的并发批准数(个位数),16 是纯防御臂。
174
+ * 🔴 dots 超限 / actor 超限 ⇒ **整只丢**而不是截:core 契约明写「The set is the identity」——截过的
175
+ * 集合、截过的 actor 名的是**另一个**身份,比缺席更坏(与 probeCause `code` 超限同判据)。
176
+ */
177
+ const MAX_EVIDENCE_DOT_ACTOR = 256;
178
+ const MAX_EVIDENCE_DOTS = 16;
179
+ /** 五词缺席词表——单一属主 = core 的 `ASK_EVIDENCE_ABSENCE_VALUES`(闭集常量,不在本仓抄词;
180
+ * 上游加词时这里自动跟随)。`z.custom` 而不是 `z.enum`:后者要非空元组字面量型,从 readonly 数组
181
+ * 常量过去只能 `as unknown as`(类型卫生门禁),而运行期判据本来就是「在 core 词表里」这一件事。 */
182
+ const AskEvidenceAbsenceSchema = z.custom((v) => typeof v === "string" && ASK_EVIDENCE_ABSENCE_VALUES.includes(v));
183
+ /** 一颗规则 add dot(core `RuleDot`:add 的持久身份 `{actor, counter}`)。 */
184
+ const RuleDotSchema = z.object({ actor: z.string().min(1).max(MAX_EVIDENCE_DOT_ACTOR), counter: z.number().int().nonnegative() }).strict();
185
+ /**
186
+ * #263 件 G2 —— `AskRequest.ruleEvidence` 的形(帧 / `card_json` / 读面共用;窄读入口是
187
+ * {@link readRuleEvidence})。三对成员各 = 值**或**五词命名缺席(`AskEvidenceAbsence`),core 引擎
188
+ * 盖章的证据恰一在场;server 只判形转录,**永不**自铸缺席词(server 铸词 = 谎报「某一层没报」)。
189
+ */
190
+ export const RuleEvidenceSchema = z
191
+ .object({
192
+ orgRevision: z.number().finite().optional(),
193
+ orgRevisionAbsent: AskEvidenceAbsenceSchema.optional(),
194
+ orgRule: z.string().max(MAX_RULE_TEXT_CHARS).optional(),
195
+ orgRuleAbsent: AskEvidenceAbsenceSchema.optional(),
196
+ personalRuleDots: z.array(RuleDotSchema).max(MAX_EVIDENCE_DOTS).optional(),
197
+ personalRuleDotsAbsent: AskEvidenceAbsenceSchema.optional(),
198
+ })
199
+ .strict();
200
+ /** 窄读的入参形(不限长孪生,分工同 {@link ProbeCauseEnvelopeSchema}):只判结构,长度/脱敏由
201
+ * {@link readRuleEvidence} 判完之后一次算定。 */
202
+ const RuleEvidenceEnvelopeSchema = z.object({
203
+ ruleEvidence: z
204
+ .object({
205
+ orgRevision: z.number().finite().optional(),
206
+ orgRevisionAbsent: AskEvidenceAbsenceSchema.optional(),
207
+ orgRule: z.string().optional(),
208
+ orgRuleAbsent: AskEvidenceAbsenceSchema.optional(),
209
+ personalRuleDots: z.array(z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative() })).optional(),
210
+ personalRuleDotsAbsent: AskEvidenceAbsenceSchema.optional(),
211
+ })
212
+ .optional(),
213
+ });
214
+ /**
215
+ * `AskRequest.ruleEvidence`(core 5.35.0 design/252 G-2)的**边界窄读**——活卡帧(`ToolApprovalFrame.
216
+ * ruleEvidence`)与 `card_json`(`buildApprovalCard`)的**唯一**铸造点,两面结构性同值(与
217
+ * {@link readProbeCause} 同款分工)。姿势逐条:
218
+ * · `safeParse`,形不合 ⇒ 按缺席处置(宪法 [2704]);未知键 strip(core additive 加键不塌卡)。
219
+ * · **恰一不变量**:一对成员(值 / 缺席词)**双在场** ⇒ 整只丢——那是矛盾证据(引擎盖章的恰一,
220
+ * 双在场只可能是第三方 producer 的坏行),半解上卡比缺席更坏。双缺席**容忍**(老 producer 行)。
221
+ * · `orgRule` 内容族(管理员写的规则文本)⇒ `redactSecrets` 后截 `MAX_RULE_TEXT_CHARS`,**先脱敏
222
+ * 再截一次算定**(`persistedRuleShadowed` R2-[medium] 教训:redact 会变长,两面各截会漂);空串丢整只。
223
+ * · `dots.actor` 身份串 ⇒ `redactSecrets`(对正常 id 恒等);超限**整只丢**(见上限顶注,身份不可截)。
224
+ * · `orgRevision` / 缺席词 verbatim(数值 / 闭集词,非内容)。
225
+ */
226
+ export function readRuleEvidence(req) {
227
+ const parsed = RuleEvidenceEnvelopeSchema.safeParse(req);
228
+ const raw = parsed.success ? parsed.data.ruleEvidence : undefined;
229
+ if (raw === undefined)
230
+ return undefined;
231
+ if ((raw.orgRevision !== undefined && raw.orgRevisionAbsent !== undefined) ||
232
+ (raw.orgRule !== undefined && raw.orgRuleAbsent !== undefined) ||
233
+ (raw.personalRuleDots !== undefined && raw.personalRuleDotsAbsent !== undefined)) {
234
+ return undefined;
235
+ }
236
+ let dots;
237
+ if (raw.personalRuleDots !== undefined) {
238
+ if (raw.personalRuleDots.length > MAX_EVIDENCE_DOTS)
239
+ return undefined;
240
+ dots = [];
241
+ for (const d of raw.personalRuleDots) {
242
+ const actor = redactSecrets(d.actor);
243
+ if (actor === "" || actor.length > MAX_EVIDENCE_DOT_ACTOR)
244
+ return undefined;
245
+ dots.push({ actor, counter: d.counter });
246
+ }
247
+ }
248
+ const orgRule = raw.orgRule !== undefined ? redactSecrets(raw.orgRule).slice(0, MAX_RULE_TEXT_CHARS) : undefined;
249
+ if (raw.orgRule !== undefined && (orgRule === undefined || orgRule === ""))
250
+ return undefined;
251
+ return {
252
+ ...(raw.orgRevision !== undefined ? { orgRevision: raw.orgRevision } : {}),
253
+ ...(raw.orgRevisionAbsent !== undefined ? { orgRevisionAbsent: raw.orgRevisionAbsent } : {}),
254
+ ...(orgRule !== undefined ? { orgRule } : {}),
255
+ ...(raw.orgRuleAbsent !== undefined ? { orgRuleAbsent: raw.orgRuleAbsent } : {}),
256
+ ...(dots !== undefined ? { personalRuleDots: dots } : {}),
257
+ ...(raw.personalRuleDotsAbsent !== undefined ? { personalRuleDotsAbsent: raw.personalRuleDotsAbsent } : {}),
258
+ };
259
+ }
168
260
  /**
169
261
  * design/172 §3.1 的**中性投影**——呈卡面看到的全部内容,与 wire 面的既有 `ToolApprovalFrame` 解耦。
170
262
  *
@@ -273,6 +365,13 @@ export const ApprovalCardSchema = z
273
365
  * `.strict()` 下旧二进制读不动带本键的新行 ⇒ 重放跳过 + 幂等重入走 park(fail-safe),`schemaVersion` 不动。
274
366
  */
275
367
  probeCause: ProbeCauseSchema.optional(),
368
+ /**
369
+ * #263 件 G2(core 5.35.0 design/252 G-2):这只 ask 背后的**规则出处证据**——org 快照 revision /
370
+ * 命中的 org 规则原文 / 被越级的个人规则 add dots,每员 = 值或五词命名缺席。展示/对账元数据,
371
+ * **永不**是裁决输入(core 契约逐字:removing it would leave every decision byte-identical)。
372
+ * 回滚窗代价与 `probeCause` 逐字同族(`.strict()` 下旧二进制读不动新行 ⇒ 重放跳过 + park fail-safe)。
373
+ */
374
+ ruleEvidence: RuleEvidenceSchema.optional(),
276
375
  /** 委派出处(子代 ask 才在场;判别键 = `fromSubagent`,core RB-39②)。 */
277
376
  fromSubagent: z.literal(true).optional(),
278
377
  sourceTaskId: z.string().max(MAX_IDENT).optional(),
@@ -351,6 +450,8 @@ export function buildApprovalCard(source, req, requiresRealApproval) {
351
450
  // #253 件 G1:`probeCause` 与两轴同款,从 `req`(core 的 AskRequest)窄读 —— 发帧点用**同一个**函数
352
451
  // 铸帧上那一份,于是「帧与卡同值」是结构事实而不是两处各挑一次键的巧合(§6.5 同款判据)。
353
452
  const probeCause = readProbeCause(req);
453
+ // #263 件 G2:`ruleEvidence` 同款(readRuleEvidence 是帧/卡唯一铸造点,redact+截长在函数内一次算定)。
454
+ const ruleEvidence = readRuleEvidence(req);
354
455
  const toolCallId = clip(source.toolCallId, MAX_IDENT);
355
456
  const sourceTaskId = clip(source.sourceTaskId, MAX_IDENT);
356
457
  const sourceAgentName = clip(source.sourceAgentName, MAX_AGENT_NAME);
@@ -373,6 +474,8 @@ export function buildApprovalCard(source, req, requiresRealApproval) {
373
474
  // #253 件 G1:结构化探针因由 —— 与活卡帧**同一个**窄读函数(`readProbeCause`),两面结构性同值;
374
475
  // 形不合/缺席 ⇒ 不铸键(语义见 `ApprovalCardSchema.probeCause` 顶注)。
375
476
  ...(probeCause !== undefined ? { probeCause } : {}),
477
+ // #263 件 G2:规则出处证据 —— 同款同源(`readRuleEvidence`);形不合/缺席 ⇒ 不铸键。
478
+ ...(ruleEvidence !== undefined ? { ruleEvidence } : {}),
376
479
  // #154 车二:候选**逐字**投影(空数组 ⇒ 不投键 —— 「没有候选」与「本部署不供候选」在卡面上都读作
377
480
  // 「本卡无此选项」,铸一个空数组只会让消费端多一条无意义的分支)。拷贝一份:卡不与调用方共享可变引用。
378
481
  ...(source.ruleSuggestions !== undefined && source.ruleSuggestions.length > 0
@@ -304,7 +304,7 @@ export async function main(argv = process.argv.slice(2)) {
304
304
  process.exit(2);
305
305
  return;
306
306
  }
307
- const modelId = liveRuntime?.modelId ?? "deepseek-v4-pro";
307
+ const modelId = liveRuntime?.modelId ?? "deepseek-v4-flash";
308
308
  const log = (msg, meta) => console.error(`[s1 run-firm] ${msg}`, meta ? JSON.stringify(meta) : "");
309
309
  const result = await runFirm({
310
310
  seeds: args.seeds,
@@ -241,7 +241,10 @@ export function buildScenarios(deps) {
241
241
  }
242
242
  /** Resolve the scenario for a request (falls back to `default`). */
243
243
  export function selectScenario(scenarios, name) {
244
- return (name && scenarios[name]) || scenarios.default;
244
+ // #193 P2-13:own-property 守卫——`Record` 字面量的裸索引对原型链键("constructor" 形状)会把
245
+ // Object.prototype 上的函数当 Scenario 返回。上游 resolve-spec 按 Object.keys 校验 ⇒ 当前不可利用,
246
+ // 本守卫是纵深防御(本函数是导出面,不得依赖单一调用方的入口校验)。
247
+ return (name && Object.hasOwn(scenarios, name) && scenarios[name]) || scenarios.default;
245
248
  }
246
249
  /** 把属主的判别式摊平成 wire 上的两个键(可用臂整键省略原因,契约=「缺席即没有理由」)。 */
247
250
  function availabilityFields(deps, toolset) {
@@ -254,7 +257,7 @@ function availabilityFields(deps, toolset) {
254
257
  export function builtinScenarioDetails(scenarios, deps) {
255
258
  const probe = (name, req, principal, fallback) => {
256
259
  try {
257
- return (scenarios[name]?.(req, principal)?.tools ?? []).map((t) => t.name);
260
+ return ((Object.hasOwn(scenarios, name) ? scenarios[name] : undefined)?.(req, principal)?.tools ?? []).map((t) => t.name); // P2-13 同族守卫
258
261
  }
259
262
  catch {
260
263
  return fallback; // 工厂 fail-loud(部署未配该场景的后端)→ 静态名单(web 仍能展示工具面选项)
@@ -286,8 +289,8 @@ export function centerScenarioDetails(specs, builtinNames, deps) {
286
289
  continue;
287
290
  if (typeof spec.name !== "string" || !SCENARIO_NAME_RE.test(spec.name))
288
291
  continue;
289
- if (!TOOLSETS[spec.toolset])
290
- continue;
292
+ if (!Object.hasOwn(TOOLSETS, spec.toolset) || !TOOLSETS[spec.toolset])
293
+ continue; // P2-13 同族守卫
291
294
  out[spec.name] = {
292
295
  name: spec.name,
293
296
  source: "center",
@@ -332,7 +335,7 @@ const SCENARIO_AVAILABLE = { available: true };
332
335
  * 词表外的 toolset(内建的 `full-body`/`team`)不依赖后端 ⇒ 恒可用。
333
336
  */
334
337
  export function scenarioAvailability(deps, toolset) {
335
- if (TOOLSETS[toolset]?.requiresRepo === true && deps.repoClient === undefined) {
338
+ if (Object.hasOwn(TOOLSETS, toolset) && TOOLSETS[toolset]?.requiresRepo === true && deps.repoClient === undefined) { // P2-13 同族守卫
336
339
  return { available: false, reason: "git_client_unconfigured" };
337
340
  }
338
341
  return SCENARIO_AVAILABLE;
@@ -372,7 +375,7 @@ export function centerScenarios(deps, specs, builtinNames, logger) {
372
375
  logger?.warn("sema_registry_scenario_invalid", { name: String(spec?.name), reason: "name must match " + String(SCENARIO_NAME_RE) });
373
376
  continue;
374
377
  }
375
- const toolset = TOOLSETS[spec.toolset];
378
+ const toolset = Object.hasOwn(TOOLSETS, spec.toolset) ? TOOLSETS[spec.toolset] : undefined; // P2-13 同族守卫
376
379
  if (!toolset) {
377
380
  logger?.warn("sema_registry_scenario_invalid", { name: spec.name, reason: `unknown toolset "${String(spec.toolset)}" (have: ${Object.keys(TOOLSETS).join(", ")})` });
378
381
  continue;
package/dist/config.d.ts CHANGED
@@ -64,7 +64,7 @@ export declare function parseNumOrFailNonNegative(name: string, raw: string | un
64
64
  * `invalid-default` 是**缺省种类**(不写 `kind` 即是它)—— 历史形 `{env,raw}` 的三个存量生产者
65
65
  * (`optFinitePositiveEnv` / `boolEnv` / `boolEnvWithLegacyNegated`)因此逐字不动,它们的断言也不破。
66
66
  */
67
- type ConfigWarningKind = "invalid-default" | "clamped" | "empty-preserved" | "entry-rejected" | "set-rejected";
67
+ type ConfigWarningKind = "invalid-default" | "clamped" | "empty-preserved" | "entry-rejected" | "set-rejected" | "ineffective";
68
68
  interface ConfigWarning {
69
69
  env: string;
70
70
  /** 环境原文。`entry-rejected` 上恒为脱敏占位 —— 那两条 env 装的是凭据,原文永不进日志。 */
package/dist/config.js CHANGED
@@ -255,6 +255,9 @@ export function logConfigDiagnostics(logger) {
255
255
  case "set-rejected":
256
256
  logger.warn("config_env_set_rejected", { env: w.env, problem: w.detail });
257
257
  break;
258
+ case "ineffective":
259
+ logger.warn("config_env_set_but_ineffective", { env: w.env, raw: w.raw, problem: w.detail });
260
+ break;
258
261
  case "invalid-default":
259
262
  logger.warn("config_env_invalid_using_default", { env: w.env, raw: w.raw, ...(w.used !== undefined ? { used: w.used } : {}) });
260
263
  break;
@@ -2038,7 +2041,15 @@ function parseLimitsHttpDomain() {
2038
2041
  // 基建成本恒 0,查不到是自己那个负号被吃掉了。
2039
2042
  toolCallMicroUsd: clampEnvWithWarn("INFRA_COST_TOOL_CALL", "0", 0),
2040
2043
  sandboxSecMicroUsd: clampEnvWithWarn("INFRA_COST_SANDBOX_SEC", "0", 0),
2041
- egressGbMicroUsd: clampEnvWithWarn("INFRA_COST_EGRESS_GB", "0", 0),
2044
+ // #193 车5(P1-4):per-run 出网计量未建(cost-taxonomy `infraUsageFromEvents` 恒 `egressBytes: 0`),
2045
+ // 该费率设成任何值都恒计 $0。旋钮保留(接真计量是产品件),但设非零必点名——静默会让 operator
2046
+ // 以为出网已计费。接上计量的那批必须同时删掉这条 push(test/config-knob-loudness #193 格看守)。
2047
+ egressGbMicroUsd: (() => {
2048
+ const v = clampEnvWithWarn("INFRA_COST_EGRESS_GB", "0", 0);
2049
+ if (v > 0)
2050
+ CONFIG_WARNINGS.push({ env: "INFRA_COST_EGRESS_GB", raw: env("INFRA_COST_EGRESS_GB", "0"), kind: "ineffective", detail: "egress metering is not wired (per-run egressBytes is a constant 0) — this rate multiplies zero and will never bill anything" });
2051
+ return v;
2052
+ })(),
2042
2053
  },
2043
2054
  drainGraceMs: clampEnvWithWarn("DRAIN_GRACE_MS", String(10 * 60 * 1000), 10_000), // SIGTERM drain window, default 10min, floor 10s(#210 B 档:把 600 当成"秒"填进来的手滑最常见,夹取后必须点名)
2044
2055
  sighupIdleGraceMs: clampEnvWithWarn("SIGHUP_IDLE_GRACE_MS", String(120_000), 5_000), // B3: SIGHUP idle-shutdown window, default 2min, floor 5s
@@ -173,4 +173,15 @@ export type RouteHandler = (req: IncomingMessage, res: ServerResponse, url: stri
173
173
  export interface RouteMiss {
174
174
  fell: boolean;
175
175
  }
176
+ /**
177
+ * #262(A-041 存在性 oracle 封口)——`not_found.run` 的**唯一**文案源,unknown 与 not-yours 两臂共用。
178
+ * 修前:unknown 臂发这条长指路文案、not-yours 臂发短文「run not found」,同 code 下按**文案长度**即可
179
+ * 枚举他人 run id 的存在性(822 行「no existence oracle」注释的意图被长短差打穿)。not-yours 臂只可能
180
+ * 拿到非 a\* id(store 查到行才有 owner 比对;a\* 与 wa\* 形 id 永不在 run store)⇒ 非 a\* 长文串足以让
181
+ * 两臂逐字节同形,指路价值不丢。钉=audit-fixes-http「#262」格(两臂全文比对);文案变更走
182
+ * api-error-text-freeze 基线流程。
183
+ */
184
+ export declare const RUN_NOT_FOUND_MESSAGE = "run not found \u2014 the id belongs to no run in this deployment's run store (a run from another server process, or an in-memory store that did not survive a restart, is not visible here)";
185
+ /** [1434] 前缀=域判别子既成契约:a\* 与 wa\* 形 id 给 TaskOutput/journal 指路;其余给 {@link RUN_NOT_FOUND_MESSAGE}。 */
186
+ export declare function runNotFoundMessage(id: string): string;
176
187
  //# sourceMappingURL=route-ctx.d.ts.map
@@ -1,2 +1,17 @@
1
- export {};
1
+ /**
2
+ * #262(A-041 存在性 oracle 封口)——`not_found.run` 的**唯一**文案源,unknown 与 not-yours 两臂共用。
3
+ * 修前:unknown 臂发这条长指路文案、not-yours 臂发短文「run not found」,同 code 下按**文案长度**即可
4
+ * 枚举他人 run id 的存在性(822 行「no existence oracle」注释的意图被长短差打穿)。not-yours 臂只可能
5
+ * 拿到非 a\* id(store 查到行才有 owner 比对;a\* 与 wa\* 形 id 永不在 run store)⇒ 非 a\* 长文串足以让
6
+ * 两臂逐字节同形,指路价值不丢。钉=audit-fixes-http「#262」格(两臂全文比对);文案变更走
7
+ * api-error-text-freeze 基线流程。
8
+ */
9
+ export const RUN_NOT_FOUND_MESSAGE = "run not found — the id belongs to no run in this deployment's run store (a run from another server process, or an in-memory store that did not survive a restart, is not visible here)";
10
+ /** [1434] 前缀=域判别子既成契约:a\* 与 wa\* 形 id 给 TaskOutput/journal 指路;其余给 {@link RUN_NOT_FOUND_MESSAGE}。 */
11
+ export function runNotFoundMessage(id) {
12
+ const aStar = /^(?:a|wa)[0-9a-f]{4,}$/i.test(id);
13
+ return aStar
14
+ ? `run not found — "${id}" looks like an agent handle (a*/wa* domain), which is never in the run store; use the TaskOutput tool (or the workflow journal / subagents surface) to read it, and the run's durable taskId (X-Task-Id) for this endpoint`
15
+ : RUN_NOT_FOUND_MESSAGE;
16
+ }
2
17
  //# sourceMappingURL=route-ctx.js.map
@@ -19,6 +19,9 @@ export declare const RUN_CANCEL_RE: RegExp;
19
19
  export declare const RUN_STEER_RE: RegExp;
20
20
  export declare const RUN_DETACH_RE: RegExp;
21
21
  export declare const RUN_COMPACT_RE: RegExp;
22
+ /** PAIR-REVIEW F-10:runs 面 404 文案统一富形——a\* 形 id(background-agent registry 句柄,`a`+hex 短形)
23
+ * 是「结构性不在 run 店」的常客([1347]⑤ 记档裁量:不改路由,只给引导)。识别句法形即补引导;
24
+ * 非 a\* 形保通用富文案(cancel 腿 [888]① 同款)。 */
22
25
  export declare const ELICIT_RESPOND_RE: RegExp;
23
26
  export declare const QUESTION_RESPOND_RE: RegExp;
24
27
  export declare const TOOL_APPROVAL_RESPOND_RE: RegExp;
@@ -21,6 +21,7 @@ import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
21
21
  import { buildActiveRunConflict } from "../active-run-conflict.js";
22
22
  import { clearTurnActivity, readTurnActivityMs } from "../../turn-activity.js";
23
23
  import { headerStr, gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
24
+ import { RUN_NOT_FOUND_MESSAGE, runNotFoundMessage } from "../route-ctx.js";
24
25
  // server.ts 侧的 routeLabel / isBillableSubmitPath 仍要用下面这些正则,故本模块导出(方向恒为 server.ts → routes/*)。
25
26
  export const RUN_ID_RE = /^\/v1\/runs\/([^/]+)(\/events)?$/;
26
27
  export const RUN_CANCEL_RE = /^\/v1\/runs\/([^/]+)\/cancel$/;
@@ -32,15 +33,8 @@ export const RUN_COMPACT_RE = /^\/v1\/runs\/([^/]+)\/compact$/;
32
33
  /** PAIR-REVIEW F-10:runs 面 404 文案统一富形——a\* 形 id(background-agent registry 句柄,`a`+hex 短形)
33
34
  * 是「结构性不在 run 店」的常客([1347]⑤ 记档裁量:不改路由,只给引导)。识别句法形即补引导;
34
35
  * 非 a\* 形保通用富文案(cancel 腿 [888]① 同款)。 */
35
- /** B7(design/158):文案逐字不动,只把「体」换成「文案」——两个调用点改走 `sendError(…, "not_found.run", …)`,
36
- * 机器码由发送点携带(errorCode 恒在),这样错误码门不必为「体来自 helper」的站点开豁免。 */
37
- function runNotFoundMessage(id) {
38
- // [1434]:前缀=域判别子既成契约——wa\*(workflow agent 观测行,1.356)与 a\* 同族放宽。
39
- const aStar = /^(?:a|wa)[0-9a-f]{4,}$/i.test(id);
40
- return aStar
41
- ? `run not found — "${id}" looks like an agent handle (a*/wa* domain), which is never in the run store; use the TaskOutput tool (or the workflow journal / subagents surface) to read it, and the run's durable taskId (X-Task-Id) for this endpoint`
42
- : "run not found — the id belongs to no run in this deployment's run store (a run from another server process, or an in-memory store that did not survive a restart, is not visible here)";
43
- }
36
+ // #262:runNotFoundMessage 与 RUN_NOT_FOUND_MESSAGE 搬 route-ctx 叶子(server.ts 的 owner 门也要同串,
37
+ // 两文件共享必须走叶子防环)——unknown not-yours 两臂自此单一文案源,oracle 封口理由见叶子头注。
44
38
  export const ELICIT_RESPOND_RE = /^\/v1\/elicitations\/([^/]+)\/respond$/;
45
39
  // §4④: the shell answers the agent's AskUserQuestion here. Same live-only + same-replica shape as elicit.
46
40
  export const QUESTION_RESPOND_RE = /^\/v1\/questions\/([^/]+)\/respond$/;
@@ -140,12 +134,12 @@ async function subagentReadGate(req, res, deps, runId) {
140
134
  const trusted = explicitOperatorOk(principal, deps.config.operatorPrincipals);
141
135
  const run = await deps.runStore.getRun(runId);
142
136
  if (!run || (!trusted && run.owner !== null && run.owner !== principal)) {
143
- sendError(res, 404, "not_found.run", "run not found");
137
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE); // #262:与 unknown 臂同串,oracle 封口
144
138
  return null;
145
139
  }
146
140
  const callerSession = new URL(req.url ?? "", "http://x").searchParams.get("session");
147
141
  if (!trusted && run.sessionId && callerSession !== run.sessionId) {
148
- sendError(res, 404, "not_found.run", "run not found");
142
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE); // #262:与 unknown 臂同串,oracle 封口
149
143
  return null;
150
144
  }
151
145
  return { run, trusted };
@@ -495,7 +489,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
495
489
  if (!run) {
496
490
  // [888]① 文案:这个 face 覆盖本部署 run store 里的全部 run(bg/durable + interactive sync 腿都建行、
497
491
  // 都注册快路)——404 只剩「id 属于别的进程/别的 store」一类(如另起的 server 进程配 in-memory store)。
498
- sendError(res, 404, "not_found.run", "run not found — the id belongs to no run in this deployment's run store (a run from another server process, or an in-memory store that did not survive a restart, is not visible here)");
492
+ sendError(res, 404, "not_found.run", runNotFoundMessage(taskId)); // #262:硬编码同串归 helper
499
493
  return;
500
494
  }
501
495
  if (!runOwnerOk(req, res, run.owner))
@@ -776,9 +770,9 @@ async function handleRunsBody(req, res, url, ctx, miss) {
776
770
  // Owner-gate (parity with preempt): an EXPLICIT operator may steer any tenant's run (supervisor intervention);
777
771
  // a normal caller only its own. 404 (not 403) for a non-owner — no existence oracle.
778
772
  if (!trusted && run.owner !== null && run.owner !== principal) {
779
- sendError(res, 404, "not_found.run", "run not found");
773
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
780
774
  return;
781
- }
775
+ } // #262 同串
782
776
  if (!runSessionAcceptOk(req, res, run, "run.steer"))
783
777
  return; // [1499] accept-phase
784
778
  // Durable-park helper: land the steer on this session's PENDING checkpoint. The checkpoint table — not the run
@@ -833,10 +827,15 @@ async function handleRunsBody(req, res, url, ctx, miss) {
833
827
  const sendParked = (status) => sendJson(res, 202, { taskId, status, delivery: "queued", messageId, ...(priority ? { priority } : {}), note: "steer parked on the checkpoint — injected when the run resumes" });
834
828
  const sendNotRunning = (error) => sendError(res, 409, "steering.not_running", error);
835
829
  // (a) Same-replica live path: the run is streaming here → inject now (drained at the next turn boundary).
830
+ // core 5.35.0(#257/#259 收口,[4022] G1):live 腿把同一个 `steerInputId` 接到 `TaskStream.steer` 的
831
+ // `inputId` 上 —— 此前只有 parked 腿去重,「202 丢在路上、客户端照常重试」打在 live 腿上就是二次注入
832
+ // (parked/live 两腿一条 ingress 键,自本版起 replay-safe 等强;live 判重窗口=流内存活期,契约成文)。
833
+ // 同 key 同载荷重放 = 幂等 no-op(照常 200,core 零二次注入零二次 human_input 帧);同 key 异载荷 =
834
+ // `steering.duplicate_input_id` → 409(与 parked 腿 sendDuplicateKey 同码同文案族)。
836
835
  const live = steerableRuns.get(taskId);
837
836
  if (live) {
838
837
  try {
839
- await live.steer(text, { trusted });
838
+ await live.steer(text, { trusted, ...(steerInputId !== undefined ? { inputId: steerInputId } : {}) });
840
839
  sendJson(res, 200, { taskId, status: "running", delivery: "applied", messageId, ...(priority ? { priority } : {}) });
841
840
  return;
842
841
  }
@@ -846,6 +845,11 @@ async function handleRunsBody(req, res, url, ctx, miss) {
846
845
  sendError(res, 422, "steering.invalid_content", e instanceof Error ? e.message : "invalid steering content");
847
846
  return;
848
847
  }
848
+ if (code === "steering.duplicate_input_id") {
849
+ parkDuplicateKey = e instanceof Error ? e.message : undefined;
850
+ sendDuplicateKey();
851
+ return;
852
+ }
849
853
  if (code !== "steering.not_running")
850
854
  throw e; // unexpected → outer handler 500
851
855
  // Raced: the stream finished between the map lookup and steer(). It may have durably SUSPENDED in the SAME
@@ -1039,16 +1043,16 @@ async function handleRunsBody(req, res, url, ctx, miss) {
1039
1043
  const taskId = compactMatch[1];
1040
1044
  const run = await deps.runStore.getRun(taskId);
1041
1045
  if (!run) {
1042
- sendError(res, 404, "not_found.run", "run not found");
1046
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
1043
1047
  return;
1044
- }
1048
+ } // #262 同串
1045
1049
  // Owner-gate (parity with steer/preempt): an explicit operator may compact any tenant's run; a normal caller
1046
1050
  // only its own. 404 (not 403) for a non-owner — no existence oracle.
1047
1051
  const operator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
1048
1052
  if (!operator && run.owner !== null && run.owner !== principal) {
1049
- sendError(res, 404, "not_found.run", "run not found");
1053
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
1050
1054
  return;
1051
- }
1055
+ } // #262 同串
1052
1056
  if (!runSessionAcceptOk(req, res, run, "run.compact"))
1053
1057
  return; // [1499] accept-phase
1054
1058
  // core 1.293:compact(opts) 收 { instructions?, signal? }。instructions=壳 /compact 的定向摘要指令,
@@ -1164,16 +1168,16 @@ async function handleRunsBody(req, res, url, ctx, miss) {
1164
1168
  }
1165
1169
  const run = await deps.runStore.getRun(taskId);
1166
1170
  if (!run) {
1167
- sendError(res, 404, "not_found.run", "run not found");
1171
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
1168
1172
  return;
1169
- }
1173
+ } // #262 同串
1170
1174
  // Owner-gate (parity with steer/compact): an explicit operator may detach on any tenant's run; a normal
1171
1175
  // caller only its own. 404 (not 403) for a non-owner — no existence oracle.
1172
1176
  const operator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
1173
1177
  if (!operator && run.owner !== null && run.owner !== principal) {
1174
- sendError(res, 404, "not_found.run", "run not found");
1178
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
1175
1179
  return;
1176
- }
1180
+ } // #262 同串
1177
1181
  if (!runSessionAcceptOk(req, res, run, "run.detach"))
1178
1182
  return; // [1499] accept-phase
1179
1183
  const live = steerableRuns.get(taskId);
@@ -1518,7 +1522,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1518
1522
  // Owner-gate via the parent run row (the child inherits the parent's tenancy — core threads the parent's
1519
1523
  // principal to the child, design/62). 404 (not 403) for a non-owner — no existence oracle.
1520
1524
  if (!run || (!trusted && run.owner !== null && run.owner !== principal)) {
1521
- sendError(res, 404, "not_found.run", "run not found");
1525
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE); // #262 同串
1522
1526
  return;
1523
1527
  }
1524
1528
  if (!runSessionAcceptOk(req, res, run, `subagent.${verb}`))
@@ -63,6 +63,13 @@ function sendSubmitResult(res, resp) {
63
63
  }
64
64
  sendJson(res, resp.status, resp.body);
65
65
  }
66
+ /**
67
+ * [3979] §2 发现性指路:`POST /v1/tasks` 收 verify/cascade 不拒、多尝试语义照常执行,但该同步形
68
+ * **没有事件通道**(契约 §4a-sext 成文;#253 的裁定=不是缺座位,是端点形状)。test 黑盒撞到
69
+ * 「200 却零事件回放」证明成文≠可发现,所以带 verify/cascade 的 200 体附本句(不改行为)。
70
+ * 注入点在 runSyncSubmitLeg 的三处 200 return(idem 缓存体自带 ⇒ peek 重放腿同形,不必二处注入)。
71
+ */
72
+ const MULTI_ATTEMPT_NO_EVENT_CHANNEL_NOTICE = "verify / cascade ran with their normal multi-attempt semantics, but the synchronous /v1/tasks endpoint has no event channel — no status/progress frames exist to replay on this shape. For observable events submit via POST /v1/runs and read GET /v1/runs/:id/events.";
66
73
  export async function handleTasks(req, res, url, ctx) {
67
74
  const miss = { fell: false };
68
75
  await handleTasksBody(req, res, url, ctx, miss);
@@ -1267,6 +1274,11 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1267
1274
  // Idempotency-Key dedup (center blocker: exactly-once billing) — concurrent + retried same-key submits
1268
1275
  // share one run; the whole run+record is wrapped so metrics/cost are counted exactly once on a replay.
1269
1276
  // ── B3 ── 同上:原文此处是同步腿的 **128 行匿名 async**,提为具名闭包。
1277
+ // [3979]:带 verify/cascade 的 200 体附事件通道指路;plain 形缺席不铸键(负控钉在 audit-fixes-http)。
1278
+ // 谓词包在**全部三处** 200 return 上而不只终局形:今天 verify/cascade 腿的 park 被 core 映射成
1279
+ // failed(nested suspend)走终局,park 两形只可能 plain 抵达——但那是 core 的映射语义,不是本腿
1280
+ // 可假设的不变式;包全三处让「core 让组合腿真 park」的未来变更不产生一个漏 notice 的形。
1281
+ const withDiscoveryNotice = (body) => prepared.verify || prepared.cascade ? { ...body, notice: MULTI_ATTEMPT_NO_EVENT_CHANNEL_NOTICE } : body;
1270
1282
  const runSyncSubmitLeg = async () => {
1271
1283
  // Durable F4/ask on the sync path — same two things as the stream path (audit functional gap):
1272
1284
  // 1. createRun a task_active claim at SUBMIT (+ heartbeat) so reapCtx's task_active guard protects
@@ -1394,7 +1406,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1394
1406
  // The submit-time row — guaranteed when runStore is present (a lost claim was 409'd before running).
1395
1407
  if (durableTaskId)
1396
1408
  await deps.runStore.setSuspended(durableTaskId);
1397
- return { status: 200, body: { taskId: durableTaskId, sessionId: prepared.spec.sessionId, status: "suspended" } };
1409
+ return { status: 200, body: withDiscoveryNotice({ taskId: durableTaskId, sessionId: prepared.spec.sessionId, status: "suspended" }) };
1398
1410
  }
1399
1411
  // design/80 D-B: a review pause (plan_review / dry_run_review) on the sync path — PARK it like a suspend
1400
1412
  // (keep task_active) + return a pollable taskId. For a plan_review GATE the /v1/assistant/tasks/:id/plan_review
@@ -1403,7 +1415,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1403
1415
  if (result.status === "needs_review" && deps.checkpointStore && deps.runStore && prepared.spec.sessionId) {
1404
1416
  if (durableTaskId)
1405
1417
  await deps.runStore.setNeedsReview(durableTaskId);
1406
- return { status: 200, body: { taskId: durableTaskId, sessionId: prepared.spec.sessionId, status: "needs_review" } };
1418
+ return { status: 200, body: withDiscoveryNotice({ taskId: durableTaskId, sessionId: prepared.spec.sessionId, status: "needs_review" }) };
1407
1419
  }
1408
1420
  // Not suspended → release the submit-time claim so the session unlocks + the ctx becomes reapable.
1409
1421
  if (durableTaskId && deps.runStore)
@@ -1415,7 +1427,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1415
1427
  // A verify/cascade NESTED suspend is mapped by core to status:"failed" while still carrying the
1416
1428
  // capability token (the genuine top-level suspend is parked above and never reaches here). Strip it so
1417
1429
  // the resume credential never goes in the response body OR the idempotency cache.
1418
- return { status: 200, body: stripCheckpointToken(result) };
1430
+ return { status: 200, body: withDiscoveryNotice(stripCheckpointToken(result)) };
1419
1431
  };
1420
1432
  const resp = await idemCache.run(idemKey, runSyncSubmitLeg, (r) => r.status < 400); // cache only success-range outcomes (a completed/failed BILLED result, replay to avoid double-bill); never a session-active 409 or a resume-at 4xx (billed nothing — a retry must re-check)
1421
1433
  sendSubmitResult(res, resp); // 体里有 retryAfterSec ⇒ 标准 retry-after 头同值(见 sendSubmitResult 顶注)
@@ -33,6 +33,7 @@ import { streamSseLog } from "./sse-log.js";
33
33
  // `clampVerifyRounds` used to be re-exported from here for the tests; that re-export was deleted (8566348) and the
34
34
  // import sat on as a dead name — tests import it from `verify-rounds.js` directly, which is the single owner.
35
35
  import { verifyRoundsFromBody } from "./verify-rounds.js";
36
+ import { RUN_NOT_FOUND_MESSAGE } from "./route-ctx.js";
36
37
  import { handleCapabilities } from "./routes/capabilities.js";
37
38
  import { handleObservability } from "./routes/observability.js";
38
39
  import { handleDiagnostics } from "./routes/diagnostics.js";
@@ -2957,7 +2958,7 @@ export function createHttpServer(rawDeps) {
2957
2958
  // 生命周期消亡后可整删);带具名 principal 的请求对哨兵行仍走下方 404 无 oracle 形。
2958
2959
  const legacyShellOwner = !deps.config.requirePrincipal && owner === "anon:shell-live" && (principal === undefined || principal === null);
2959
2960
  if (!legacyShellOwner && owner !== null && principal !== owner) {
2960
- sendError(res, 404, "not_found.run", "run not found"); // don't reveal existence to non-owners
2961
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE); // #262:与 unknown 臂同串——「don't reveal existence」的意图此前被长短文差打穿
2961
2962
  return false;
2962
2963
  }
2963
2964
  return true;
@@ -2976,7 +2977,7 @@ export function createHttpServer(rawDeps) {
2976
2977
  * those owner gates is a deliberate [1498]⑥ decision, not this phase's. A session-unbound run has nothing
2977
2978
  * to assert against. */
2978
2979
  const sessionAssertAbsentWarned = new Set();
2979
- function runSessionAcceptOk(req, res, run, face, notFoundError = "run not found") {
2980
+ function runSessionAcceptOk(req, res, run, face, notFoundError = RUN_NOT_FOUND_MESSAGE) {
2980
2981
  if (!run.sessionId)
2981
2982
  return true;
2982
2983
  const asserted = new URL(req.url ?? "", "http://x").searchParams.get("session");
@@ -77,6 +77,10 @@ export declare const FAIL_OPEN_TAGS: {
77
77
  readonly cls: "F";
78
78
  readonly note: "#213 陈旧 claim 接管:claim 已 rename 进 tmp/ 隔离名(接管本体已完成、正确性不受影响),但隔离件 rmSync 失败留在 tmp。放行的最坏后果 = tmp 里多一个死文件(不在 activeDir,hydrate 永不把它当 claim 复活);故 F 类。必须留痕:反复删不掉 = tmp 权限/fs 病灶,静默吞掉会让 tmp 无声膨胀。";
79
79
  };
80
+ readonly "server.otel.export-failed": {
81
+ readonly cls: "F";
82
+ readonly note: "#193 车5(staleness-sweep P1-3):OTLP 周期导出失败(HTTP 非 2xx 或 fetch 拒绝)⇒ 丢弃本轮快照继续服务。导出本就 best-effort(collector 打嗝绝不影响 serving,方向不改),但此前唯一留痕是装配层 onError 的一条 warn——**metrics 轴零信号**,而 USAGE 力荐的 Prometheus-only 部署恰好只看 metrics ⇒ collector 持续宕机对运维完全不可见(遥测腿自盲)。放行的最坏后果 = 一段时间的指标断供;计数让「断供正在发生」本身成为一条指标。";
83
+ };
80
84
  };
81
85
  /** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
82
86
  export type FailOpenTag = keyof typeof FAIL_OPEN_TAGS;
@@ -100,6 +100,10 @@ export const FAIL_OPEN_TAGS = {
100
100
  cls: "F",
101
101
  note: "#213 陈旧 claim 接管:claim 已 rename 进 tmp/ 隔离名(接管本体已完成、正确性不受影响),但隔离件 rmSync 失败留在 tmp。放行的最坏后果 = tmp 里多一个死文件(不在 activeDir,hydrate 永不把它当 claim 复活);故 F 类。必须留痕:反复删不掉 = tmp 权限/fs 病灶,静默吞掉会让 tmp 无声膨胀。",
102
102
  },
103
+ "server.otel.export-failed": {
104
+ cls: "F",
105
+ note: "#193 车5(staleness-sweep P1-3):OTLP 周期导出失败(HTTP 非 2xx 或 fetch 拒绝)⇒ 丢弃本轮快照继续服务。导出本就 best-effort(collector 打嗝绝不影响 serving,方向不改),但此前唯一留痕是装配层 onError 的一条 warn——**metrics 轴零信号**,而 USAGE 力荐的 Prometheus-only 部署恰好只看 metrics ⇒ collector 持续宕机对运维完全不可见(遥测腿自盲)。放行的最坏后果 = 一段时间的指标断供;计数让「断供正在发生」本身成为一条指标。",
106
+ },
103
107
  };
104
108
  /**
105
109
  * 断流丢帧该记哪个 tag —— **按帧型分类**,纯函数(与写流的那条闭包解耦,才单测得动)。
@@ -1,3 +1,4 @@
1
+ import { recordFailOpen } from "./fail-open.js";
1
2
  const CUMULATIVE = 2; // OTel AggregationTemporality.CUMULATIVE
2
3
  /** uint64 nanos as a string (OTLP/JSON encodes 64-bit ints as strings; ms×1e6 overflows Number). */
3
4
  const nano = (ms) => (BigInt(ms) * 1000000n).toString();
@@ -82,10 +83,15 @@ export function startOtlpExporter(metrics, opts) {
82
83
  body: JSON.stringify(payload),
83
84
  signal: AbortSignal.timeout(Math.max(1_000, opts.intervalMs)),
84
85
  });
85
- if (!res.ok)
86
+ // #193 车5(P1-3):两条失败臂都要过 fail-open 计数——onError 只到日志轴,而 Prometheus-only
87
+ // 部署只看 metrics,此前 collector 持续宕机在 metrics 轴零信号(遥测腿自盲)。
88
+ if (!res.ok) {
89
+ recordFailOpen("server.otel.export-failed", `http_${res.status}`);
86
90
  opts.onError?.(new Error(`OTLP export HTTP ${res.status}`));
91
+ }
87
92
  }
88
93
  catch (err) {
94
+ recordFailOpen("server.otel.export-failed", String(err));
89
95
  opts.onError?.(err); // export is best-effort — a collector hiccup must never affect serving
90
96
  }
91
97
  finally {
@@ -123,7 +123,14 @@ export declare class SqlImageBake {
123
123
  retainedFrom(bakeId: string): Promise<number>;
124
124
  /** Events with seq strictly greater than `afterSeq` (0 for the whole stream), ascending. */
125
125
  getEvents(bakeId: string, afterSeq: number): Promise<BakeEvent[]>;
126
- /** Coarse 4-value lifecycle transition (the column the SSE reader polls for terminality). */
126
+ /**
127
+ * Coarse 4-value lifecycle transition (the column the SSE reader polls for terminality).
128
+ *
129
+ * @deprecated #193 车7(staleness-sweep P2-12):**test-only**——生产零调用。生产唯一写入路径是
130
+ * `ingestBakeLine`(routes/images.ts),它只经 `setState`(denormalize)与 `setTerminal` 驱动状态;
131
+ * 本方法是绕开「status 只由 state/done 驱动」不变量的唯一直接写口,**禁止新增生产调用**。留而不删
132
+ * 的唯一理由=三个集成测试用它铸中间态,整删随该套件下次翻新一并做。
133
+ */
127
134
  setStatus(bakeId: string, status: BakeStatus): Promise<void>;
128
135
  /** Denormalize build.sh's 8-value `state` for the UI progress bar (NEVER drives terminality, P2.3). */
129
136
  setState(bakeId: string, state: BakeState): Promise<void>;
@@ -494,7 +494,14 @@ export class SqlImageBake {
494
494
  ts: iso(r.ts),
495
495
  }));
496
496
  }
497
- /** Coarse 4-value lifecycle transition (the column the SSE reader polls for terminality). */
497
+ /**
498
+ * Coarse 4-value lifecycle transition (the column the SSE reader polls for terminality).
499
+ *
500
+ * @deprecated #193 车7(staleness-sweep P2-12):**test-only**——生产零调用。生产唯一写入路径是
501
+ * `ingestBakeLine`(routes/images.ts),它只经 `setState`(denormalize)与 `setTerminal` 驱动状态;
502
+ * 本方法是绕开「status 只由 state/done 驱动」不变量的唯一直接写口,**禁止新增生产调用**。留而不删
503
+ * 的唯一理由=三个集成测试用它铸中间态,整删随该套件下次翻新一并做。
504
+ */
498
505
  async setStatus(bakeId, status) {
499
506
  await this.db.query(this.q("UPDATE image_bake SET status = ?, updated_at = ? WHERE bake_id = ?", "UPDATE image_bake SET status = $1, updated_at = $2 WHERE bake_id = $3"), [status, new Date(), bakeId]);
500
507
  }
@@ -17,7 +17,16 @@ export declare class SqlImageIndex {
17
17
  private q;
18
18
  /** Stable index id = sha256(repo@digest) — identity is the digest, not the mutable tag. */
19
19
  static idFor(repo: string, digest: string): string;
20
- /** Register or update an index row (idempotent on repo+digest). Returns the row id. */
20
+ /**
21
+ * Register or update an index row (idempotent on repo+digest). Returns the row id.
22
+ *
23
+ * @deprecated #193 车7(staleness-sweep P2-11):**生产零调用**——曾声称的 operator
24
+ * `/v1/images/register` 路径实际走 `registerBuilding`(routes/images.ts),本方法只剩集成测试当
25
+ * seed 口在用。危险语义原样保留着:UNCONDITIONAL upsert 会把 `published` 行打回 `building`
26
+ * (正是 §P2.10 静默 de-publish 病的成因,`registerBuilding` 因此才带 STATUS-MONOTONIC 守卫)。
27
+ * **禁止新增生产调用**;测试 seed 请优先走 `registerBuilding`(不需要覆盖语义时)。留而不删的
28
+ * 唯一理由=16 处 pg 集成测试 seed 依赖,整删随该套件下次翻新一并做。
29
+ */
21
30
  upsert(e: ImageIndexUpsert): Promise<string>;
22
31
  /**
23
32
  * STATUS-MONOTONIC register (IMAGE-API-DESIGN.md §P2.10 MUST-FIX) — the auto-register path the bake runner uses.
@@ -30,8 +39,9 @@ export declare class SqlImageIndex {
30
39
  * isn't already `published` — a late/duplicate `done` for an already-promoted digest is a no-op on the
31
40
  * lifecycle, never a regression. Returns the row id.
32
41
  *
33
- * Distinct from the unconditional `upsert` (kept for the seed + the operator `/v1/images/register` path); this
34
- * is the bake auto-register's ONLY write into the index. Default status is `building` (P2 registers
42
+ * Distinct from the unconditional `upsert` (#193 车7 更正:那条 operator `/v1/images/register` 路径
43
+ * **也走本方法**——routes/images.ts:446 `idx.registerBuilding(...)`;`upsert` @deprecated,只剩
44
+ * 测试 seed 在用)。This is the bake auto-register's ONLY write into the index. Default status is `building` (P2 registers
35
45
  * node-local/quarantine `building` artifacts; P3's verify gate is what flips `building → published`).
36
46
  *
37
47
  * The `IF(...published...)` (TiDB) / `CASE WHEN...published...` (PG) guards are the monotonic de-publish
@@ -180,7 +180,16 @@ export class SqlImageIndex {
180
180
  static idFor(repo, digest) {
181
181
  return createHash("sha256").update(`${repo}@${digest}`).digest("hex").slice(0, 64);
182
182
  }
183
- /** Register or update an index row (idempotent on repo+digest). Returns the row id. */
183
+ /**
184
+ * Register or update an index row (idempotent on repo+digest). Returns the row id.
185
+ *
186
+ * @deprecated #193 车7(staleness-sweep P2-11):**生产零调用**——曾声称的 operator
187
+ * `/v1/images/register` 路径实际走 `registerBuilding`(routes/images.ts),本方法只剩集成测试当
188
+ * seed 口在用。危险语义原样保留着:UNCONDITIONAL upsert 会把 `published` 行打回 `building`
189
+ * (正是 §P2.10 静默 de-publish 病的成因,`registerBuilding` 因此才带 STATUS-MONOTONIC 守卫)。
190
+ * **禁止新增生产调用**;测试 seed 请优先走 `registerBuilding`(不需要覆盖语义时)。留而不删的
191
+ * 唯一理由=16 处 pg 集成测试 seed 依赖,整删随该套件下次翻新一并做。
192
+ */
184
193
  async upsert(e) {
185
194
  // 🔴 PG-only identity-axis validation (codex R17-H1/R19) — TiDB has no NUL/lone-surrogate column
186
195
  // constraint, so it stores repo/tenantId verbatim; PG rejects up front (see file-header dialect delta).
@@ -226,8 +235,9 @@ export class SqlImageIndex {
226
235
  * isn't already `published` — a late/duplicate `done` for an already-promoted digest is a no-op on the
227
236
  * lifecycle, never a regression. Returns the row id.
228
237
  *
229
- * Distinct from the unconditional `upsert` (kept for the seed + the operator `/v1/images/register` path); this
230
- * is the bake auto-register's ONLY write into the index. Default status is `building` (P2 registers
238
+ * Distinct from the unconditional `upsert` (#193 车7 更正:那条 operator `/v1/images/register` 路径
239
+ * **也走本方法**——routes/images.ts:446 `idx.registerBuilding(...)`;`upsert` @deprecated,只剩
240
+ * 测试 seed 在用)。This is the bake auto-register's ONLY write into the index. Default status is `building` (P2 registers
231
241
  * node-local/quarantine `building` artifacts; P3's verify gate is what flips `building → published`).
232
242
  *
233
243
  * The `IF(...published...)` (TiDB) / `CASE WHEN...published...` (PG) guards are the monotonic de-publish
@@ -1,6 +1,6 @@
1
1
  import { type AskRequest, type AskOutcome } from "@sema-agent/core";
2
2
  import type { CardRulePersisted, RuleConsentLane } from "./rules-consent.js";
3
- import { type ApprovalRequestFrame, type ApprovalRevokeFrame } from "./approval-card.js";
3
+ import { type ApprovalRequestFrame, type ApprovalRevokeFrame, type RuleEvidenceProjection } from "./approval-card.js";
4
4
  import type { ApprovalAskStore } from "./plugins/approval-ask-store-sql.js";
5
5
  import { type GovernanceAskMarks } from "./governance-ask-marks.js";
6
6
  /** A-002.1:审批 gate kind 闭集(core gateMatch 的 `human`/`irreversible_ask` ↔ outcome.gate "policy_ask")。
@@ -143,6 +143,25 @@ export interface ToolApprovalFrame {
143
143
  total: number;
144
144
  };
145
145
  };
146
+ /**
147
+ * #263 件 G2(core 5.35.0 design/252 G-2;**ADDITIVE**,`"tool_approval"` only)—— 这只 ask 背后的
148
+ * **规则出处证据**(`AskRequest.ruleEvidence`,引擎在 gate 内盖章):org 快照 `orgRevision` / 命中的
149
+ * org 规则原文 `orgRule` / 被越级个人规则的 add dots `personalRuleDots`,**每员 = 值或五词命名缺席**
150
+ * (`not_wired|not_adjudicated|unavailable|no_match|not_reported`,core `AskEvidenceAbsence` 闭集)。
151
+ *
152
+ * 🔴 **缺席词表是本键的存在理由**:裸 `undefined` 对「没接治理层」与「治理源读不动」是同一个答案,
153
+ * 审计链会把后者重构成「这次调用无治理」。`"unavailable"` 是承重员:org 层报了「看不见组织规则」,
154
+ * 手上这只 ask 是随之而来的 fail-closed 收紧——不是任何已发布规则要求的 ask。
155
+ *
156
+ * 🔴 **展示/对账元数据,永不是裁决输入**(core 契约逐字:removing it would leave every decision
157
+ * byte-identical);server 逐字转录**引擎盖章**的那一份,**永不自铸缺席词**(server 铸词 = 谎报
158
+ * 「某一层没报」)。窄读走 `approval-card.ts` 的 `readRuleEvidence`(卡也用它 ⇒ 帧与 `card_json`
159
+ * 同值;redact+截长在函数内一次算定,`persistedRuleShadowed` R2-[medium] 同款)。
160
+ *
161
+ * 与 `persistedRuleShadowed` **刻意分列**:那是**人读的**越级规则原文(display 值,非身份通道),
162
+ * 本键的 dots 是那条规则的**机器身份**(a rule is a set of adds;数组即身份,消费端禁 join 成标量)。
163
+ */
164
+ ruleEvidence?: RuleEvidenceProjection;
146
165
  /** "tool_approval" only: the tool call's args, secret-redacted, UNTRUSTED-for-display. Absent (with
147
166
  * `argsOmitted: true`) when over the byte cap or unserializable. */
148
167
  args?: unknown;
@@ -48,7 +48,7 @@ import { uuidv7, MAX_RULE_TEXT_CHARS } from "@sema-agent/core";
48
48
  import { redactDeep, redactSecrets } from "./trace/redact.js";
49
49
  import { createLogger } from "./observability/logger.js";
50
50
  import { deriveAskId, deriveBatchId, MAX_DECISION_NOTE_CHARS } from "./approval-ask-machine.js";
51
- import { ApprovalCardEnvelopeSchema, MAX_RULE_SUGGESTIONS, buildApprovalCard, buildApprovalCardEnvelope, buildApprovalRequestFrame, buildRevokeFrame, readProbeCause, } from "./approval-card.js";
51
+ import { ApprovalCardEnvelopeSchema, MAX_RULE_SUGGESTIONS, buildApprovalCard, buildApprovalCardEnvelope, buildApprovalRequestFrame, buildRevokeFrame, readProbeCause, readRuleEvidence, } from "./approval-card.js";
52
52
  import { governanceAskMarksFor, runWithGovernanceAskScope } from "./governance-ask-marks.js";
53
53
  /** #151 车2:本模块自有的日志出口——同 config-provider.ts/runtime-caps-resolver.ts 先例(协调器不走
54
54
  * DI logger,构造签名是设计定稿钉死的三键 options bag,加第四个 logger 键属于重议已裁事项)。仅用于
@@ -903,6 +903,9 @@ export class ToolApprovalCoordinator {
903
903
  // #253 件 G1:结构化探针因由。窄读走 `approval-card.ts` 的 `readProbeCause` —— **卡也用它**,
904
904
  // 所以活卡帧与 `card_json` 是同一份值(而不是两处各挑一次键)。缺席/形不合 ⇒ 不铸键。
905
905
  ...((c) => (c !== undefined ? { probeCause: c } : {}))(readProbeCause(req)),
906
+ // #263 件 G2:规则出处证据。同款同源(`readRuleEvidence`,redact+截长函数内一次算定);
907
+ // 缺席/形不合/恰一不变量被破 ⇒ 不铸键(语义见字段顶注)。
908
+ ...((c) => (c !== undefined ? { ruleEvidence: c } : {}))(readRuleEvidence(req)),
906
909
  ...(bounded.omitted ? { argsOmitted: true } : { args: bounded.args }),
907
910
  };
908
911
  // #151 车2(design/172 §3.3 D3 窗长三元):有效窗——legDeadlineMonotonic 缺席时退化成 ttlMs(D1 零
@@ -26,7 +26,7 @@ type BgNotifExcluded = "kind" | "sessionScoped" | "owner" | "scope" | "descripti
26
26
  type _GuardBgNotif = AssertAllKeysHandled<Exclude<keyof BackgroundChildEvent, BgNotifProjected | BgNotifExcluded>>;
27
27
  type RosterProjected = "name" | "agentId" | "sessionId" | "toolUseId" | "owner" | "scope" | "sessionScoped" | "rootSessionId" | "model" | "modelFallback" | "createdAt";
28
28
  type _GuardRoster = AssertAllKeysHandled<Exclude<keyof RosterEntry, RosterProjected>>;
29
- type AskProjected = "toolName" | "toolCallId" | "args" | "message" | "sourceTaskId" | "fromSubagent" | "sourceAgentName" | "delegation" | "ruleSuggestions" | "persistedRuleShadowed" | "probeCause";
29
+ type AskProjected = "toolName" | "toolCallId" | "args" | "message" | "sourceTaskId" | "fromSubagent" | "sourceAgentName" | "delegation" | "ruleSuggestions" | "persistedRuleShadowed" | "probeCause" | "ruleEvidence";
30
30
  type AskExcluded = "preview" | "principal" | "requiresRealApproval" | "riskAxes" | "boundInputHash" | "isDelegatedChild" | "probeReason";
31
31
  type _GuardAsk = AssertAllKeysHandled<Exclude<keyof AskRequest, AskProjected | AskExcluded>>;
32
32
  type TaskEventHandled = "text_delta" | "reasoning_delta" | "tool_start" | "tool_end" | "turn_end" | "compacted" | "diagnostics" | "message_committed" | "status" | "task_notification" | "task_progress" | "steering_injected" | "workspace_changed" | "done" | "context_usage" | "compaction_outcome" | "human_input" | "wiring_manifest";
@@ -94,6 +94,7 @@ export type TraceBlock = {
94
94
  structured?: unknown;
95
95
  errorCode?: string;
96
96
  settledBy?: ApprovalSettledBy;
97
+ approver?: string;
97
98
  eventId?: string;
98
99
  parentToolCallId?: string;
99
100
  } | {
@@ -136,6 +137,7 @@ export declare function toolEndEventData(ev: {
136
137
  structured?: unknown;
137
138
  errorCode?: string;
138
139
  settledBy?: ApprovalSettledBy;
140
+ approver?: string;
139
141
  eventId?: string;
140
142
  parentToolCallId?: string;
141
143
  }): Record<string, unknown>;
@@ -176,6 +176,14 @@ export function toolEndEventData(ev) {
176
176
  // 一律不铸键 —— 既不折 "human" 也不折 null,把「不知道」如实表达成「没有这个键」。
177
177
  // 值是引擎铸的枚举短串、非用户内容 ⇒ 不脱敏。
178
178
  ...(isApprovalSettledBy(ev.settledBy) ? { settledBy: ev.settledBy } : {}),
179
+ // core 5.35.0 design/252 G-7(#263 提货批,[4022] G3):`approver` = 这次结算**是谁的**——审批通道
180
+ // 自报的结算方标识串,core `screenApproverAttribution` 筛形后逐字转录在 `settledBy` 旁,两键**合读**
181
+ // (`settledBy:"timeout"` + approver = 超时的那条队列名,不是拒绝的人)。与 settledBy 同族三条纪律:
182
+ // ① 只挂在经审批结算的 tool_end 上;缺席 = 通道没报名字,**永不**意味「没人批」或「有人批」;
183
+ // ② core 只筛形不鉴别(转录不是认证)—— server 同姿势,不折不铸,typeof 判形即过境;
184
+ // ③ 值是**宿主派生的身份串**(login/email/账号 id),照 humanInputEventData 的 issuer/actor.id
185
+ // 先例过 redactSecrets(对正常标识恒等,对密钥形打码;core 已拒控制符/超长,无需重复筛)。
186
+ ...(typeof ev.approver === "string" && ev.approver !== "" ? { approver: redactSecrets(ev.approver) } : {}),
179
187
  ...identityFields(ev),
180
188
  };
181
189
  }
@@ -737,6 +745,9 @@ export function toolResultFieldsOf(d) {
737
745
  // 🔴 读腿的闭集门是**独立的一道**,不是写侧那道的复述:账本行可以来自任何引擎世代、任何第三方
738
746
  // producer,写侧的门管不到它们;这里放行 = 把一个 core 从未发过的词直接送上 wire。
739
747
  ...(isApprovalSettledBy(d.settledBy) ? { settledBy: d.settledBy } : {}),
748
+ // core 5.35.0 G-7(#263):结算归属(settledBy 旁的 WHOSE)——写侧已 redactSecrets 后落账,读腿
749
+ // 同键过境;判形独立一道(同上款理由),开集身份串故只判 string 非空,不设词表。
750
+ ...(typeof d.approver === "string" && d.approver !== "" ? { approver: d.approver } : {}),
740
751
  ...identityFields(d),
741
752
  };
742
753
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.22.0",
3
+ "version": "7.23.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -54,7 +54,7 @@
54
54
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
55
55
  },
56
56
  "dependencies": {
57
- "@sema-agent/core": "^5.34.0",
57
+ "@sema-agent/core": "^5.35.0",
58
58
  "@sema-agent/registry-core": "^0.16.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",
@@ -69,7 +69,7 @@
69
69
  "sharp": "^0.35.3"
70
70
  },
71
71
  "devDependencies": {
72
- "@sema-agent/sdk": "^6.17.2",
72
+ "@sema-agent/sdk": "7.1.0-rc.1",
73
73
  "@types/libsodium-wrappers": "^0.7.14",
74
74
  "@types/node": "22.10.2",
75
75
  "@types/pg": "^8.20.0",