# 调用方手册(OA / 智能体 / 你自己)

> 一句话心智模型:**你只对一个地址发普通 HTTP 请求——LB `http://<host>:8090`;请求体里只写"要做什么"。**
> model / tools / prompt / 安全策略由服务端 `resolveSpec` 注入,**永远不要也不能从请求体传**(凭据不进模型/请求体)。
> 你不"调度 worker",你只发任务;LB + TiDB 自己协调。每个字段都来自当前真实代码。

---

## 0. 启动（`<host>:8090` 就是你跑起来的这个服务——它不存在直到你部署它）

> 部署 = 把本仓跑在一台**能连到模型网关 + TiDB** 的机器上;那台机器的 `IP:8090` 就是你的 `<host>:8090`。
> 该机器需要 **Node 22 / Bun**(路线 A,无 docker)**或** **Docker**(路线 B)——二选一。

**路线 A — 不用 docker(单实例,最简单)**
```bash
npm install                              # 或 bun install(依赖全部公开在 npmjs)
MODEL_GATEWAY_BASEURL=https://<your-gateway>/v1 MODEL_ID=<your-model-name> \
DB_BACKEND=mysql MYSQL_HOST=… MYSQL_PORT=6000 MYSQL_USER=… MYSQL_PASSWORD=… MYSQL_DATABASE=sema_server \
npm run dev                              # 或 bun run src/main.ts → http://<本机IP>:8090
```
自己用 systemd/pm2 守护即可;无 LB、无 compose。多实例集群再走路线 B。

**路线 B — docker（最小,单 worker,内存模式,不需要数据库;只支持同步 `/v1/tasks`）**
```bash
cd sema-server
MODEL_GATEWAY_BASEURL=https://<your-gateway>/v1 MODEL_ID=<your-model-name> \
SESSION_BACKEND=memory \
docker compose up --build
# 起来后 OA 只认 http://<host>:8090(LB)
```

**生产(N worker 集群,需 MySQL 协议库——MySQL(≥8.0)/ MariaDB(≥10.5)/ TiDB 均可;支持异步 `/v1/runs` + 断线重连)**

> 🔒 7.60.0 起本仓在**每条池连接**上把会话隔离级钉死并**回读复核**:MySQL 协议腿 `REPEATABLE READ`
> + TiDB 悲观事务模式(设不上就拒启),PG 腿 `READ COMMITTED`(同样是设了再验,不问服务器缺省 ——
> 把 PG 钉成 RR 或让部署把默认配成 RR/SERIALIZABLE,都会让每条 `FOR UPDATE` 变成 `40001`)。本仓所有
> `SELECT … FOR UPDATE` 的单赢家判据都以此为前提。运维读面:
> `GET /v1/diagnostics/wiring` 的 `sqlEngine` 段与 `GET /v1/capabilities` 的 `sql` 位。判据与升级句见
> `docs/DEPLOY-PREREQS.md`「事务读语义的结构保证」。
```bash
IMAGE_REGISTRY=<registry-namespace> \
DB_BACKEND=mysql MYSQL_HOST=… MYSQL_USER=… MYSQL_PASSWORD=… MYSQL_DATABASE=sema_server \
MODEL_GATEWAY_BASEURL=… MODEL_ID=<your-model-name> \
docker compose up --build --scale service=4     # 4 个无状态 worker，nginx LB 扇发，TiDB 协调
```

> 拓扑:LB(入口)→ N 个一样的 worker → 共享 SQL 库(数据中心;MySQL 协议或 PG)+ 模型网关。无中央调度器,跨 worker 靠 DB 协调。

**可选 — 多网关 failover + Anthropic 路由(消除单网关 SPOF)**
```bash
# ① 同协议冗余:主网关挂(连不上/上游开始前就失败)→ 按序切到备网关(同一 model id)
MODEL_GATEWAY_BASEURL=http://gw-a:8000/v1  MODEL_GATEWAY_FALLBACK_URLS=http://gw-b:8000/v1,http://gw-c:8000/v1
# ② 云 Anthropic 路由:provider="anthropic" 的模型走云 /v1/messages(带 prompt 缓存断点),其余走本地网关
ANTHROPIC_API_KEY=sk-ant-…   # 可选:ANTHROPIC_BASE_URL / ANTHROPIC_VERSION / ANTHROPIC_CACHE_BREAKPOINTS=true
```
- 两个变量都不设 = 和以前**逐字节一致**(单网关)。
- **failover ≠ Anthropic↔vLLM**:failover 给所有 brain 发**同一个 model**(同协议同 id 的冗余);云↔本地是**按 `model.provider` 路由**的选择,不是故障转移(两者 model id/参数不同,不能透明互切)。设 `MODEL_PROVIDER=anthropic` + `MODEL_ID=claude-…` 让整个服务走 Anthropic。
- **缺省推断**:`MODEL_PROVIDER` **未设**、但显式配了 Anthropic 协议 base URL(`ANTHROPIC_BASE_URL`)**和**对应凭证(`ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`)时,缺省自动判 `anthropic` 并打一行启动 warn(`model_provider_inferred`)——纯净机直连 Anthropic 兼容上游不再需要显式第七个键。显式 `MODEL_PROVIDER` 恒赢;只给 base URL 或只给凭证不推断;两者皆无 = `gateway`,与以前逐字节一致。
- 启动日志的 `brain` 字段会回显当前组合(`failover` / `anthropicRoute` / `anthropicCacheBreakpoints`)。

**可选 — 韧性栈:分级超时 + 断路器(core 1.38，叠在 failover 之下)**
```bash
# ③ 分级超时:任务级 limits.maxWalltimeMs(6.0.0 起,毫秒)之下补两段,各记为可重试的 [network] → 触发重试/断路器/failover
MODEL_CONNECT_TIMEOUT_MS=8000        # fetch 迟迟不返响应头(网关连不上)→ 中止
MODEL_FIRST_TOKEN_TIMEOUT_MS=30000   # SSE 已开但迟迟不吐第一个 delta(网关 hang);reasoning 的首个 thinking 也算首 token
                                     # 缺省 600000(7.71.0 起,原 120000):自托管后端长 prefill 下首字合理地要等几分钟,
                                     # 120s 会把一次正常的慢启动判成 [network] 失败去重试。快失败部署显式设回 120000;0=关
MODEL_IDLE_TIMEOUT_MS=20000          # (1.40.1)出过 token 后中途卡死:每个 delta 重置,静默超时→中止(补 first-token 只管首字)
# ④ 断路器:主网关连败 N 次即"开路"→ 快速失败,让 failover 立刻切备(不再逐个等超时)
MODEL_CIRCUIT_BREAKER=true  MODEL_CB_FAILURE_THRESHOLD=5  MODEL_CB_COOLDOWN_MS=30000
# ⑤ 每次调用的重试上限(0–20)。**不设 = 用引擎默认**(server 不再写死它)
GATEWAY_MAX_RETRIES=10               # openai 兼容腿(第三方限流 provider 走的就是这条)
ANTHROPIC_MAX_RETRIES=10             # 云 Anthropic 腿
```
- **全部默认关**(超时=0、断路器=false)→ 不设就和以前**逐字节一致**。
- 断路器**只在配了 `MODEL_GATEWAY_FALLBACK_URLS`(≥2 路)时才有意义**——它的价值是"开路即快速失败 → failover 立刻切备";单网关下它是 no-op(启动日志 `circuitBreakerNoop` 会提示)。只 `network/server/rate_limit` 计入连败,`auth`/`invalid_request` 不计(坏 key 熔断整网关无意义)。备用网关(最后一路)不套断路器。
- **重试上限**(2026-07-31):两个键**不设就不传给引擎** —— 引擎默认当家。以前这里是 server 侧硬编码 `2`(openai 腿甚至零配置出口),而**显式传参压过引擎默认**,于是引擎抬默认对 server 部署毫无效果;第三方限流 provider 下"重试两次就放弃"正是由此而来。现在:不设=继承引擎默认(抬默认那天自动跟上),设了=按设的走。钉在 `test/brain.test.ts` 的「网关腿重试次数」两条上。
- 断路器状态:配了 `SESSION_BACKEND=mysql` 时自动用**跨副本共享态**(SQL `circuit_breaker` 表,写穿+刷新最终一致),否则进程内 Map。启动日志 `breakerState` 字段回显 `shared(mysql)`/`in-process`/`off`。

**可选 — WebSearch 后端(core 只留注入口,不自带任何 provider;部署 env 七键)**
```bash
WEB_SEARCH_PROVIDER=brave|tavily|searxng   # 唯一的"装配开关":合法词才挂 WebSearch 工具;缺席/非法词 = 不装配(不是挂了报错)
WEB_SEARCH_API_KEY=…                       # brave/tavily 必需(searxng 不读这一键);只进 backend 闭包,永不进模型 prompt 或工具参数
WEB_SEARCH_ENDPOINT=https://searx.example  # searxng 必需(实例地址);brave/tavily 下是可选的 base-URL 覆盖(代理/测试)
WEB_SEARCH_MAX_RESULTS=10                  # 1..20(夹逼);缺席/非正数/非数字 → 用 backend 默认 10;小数向下取整
WEB_SEARCH_TIMEOUT_MS=10000                # 单次搜索墙钟(ms),下限 1000;缺席/非正数/非数字 → 用 backend 默认 10000
WEB_SEARCH_SEARXNG_PARAMS="engines=bing,duckduckgo;language=zh-CN"  # 仅 searxng 腿消费;`;` 分隔 k=v 对;一个都解析不出 → 键整个不铸(不产出空对象)
WEB_SEARCH_PROBE_ON_BOOT=true              # boot 期一次性真出网探活,默认 OFF;只认 "true"/"1"(trim+小写后比较),含糊值当没开
```

七键逐一(`src/plugins/web-search.ts` `webSearchConfigFromEnv` §294-309;类型/默认/坏值登记见
`src/config-catalog.ts:654-662`):

| env 键 | 类型 | 默认 | 缺席行为 | 坏值行为 |
|---|---|---|---|---|
| `WEB_SEARCH_PROVIDER` | 闭集(`brave`\|`tavily`\|`searxng`) | 无 | WebSearch 工具整体不装配(功能缺席,无 warn) | 三词之外的任何值 = 同缺席处理,不装配、不 warn(`webSearchConfigFromEnv` 的词表守卫 `isWebSearchProvider`)。**读面(S-382 起)**:`GET /v1/capabilities` 的 `webSearch.backend` 把这一格的生效值广告成闭集词(缺席/坏值都报 `"none"` —— 两者行为本就相同);端点/密钥/配额**不上 wire**,那些仍只在 operator 面 `GET /v1/config/catalog` |
| `WEB_SEARCH_API_KEY` | secret string | 无 | brave/tavily:后端仍会装配(装配只看 `PROVIDER`),**首次真实工具调用**时抛 `WEB_SEARCH_API_KEY is required for the <provider> provider`(`braveSearch` / `tavilySearch` 的首行守卫);searxng:本键无消费点 | 空字符串同缺席(`env.WEB_SEARCH_API_KEY ?` 只认真值,`webSearchConfigFromEnv` 的条件展开) |
| `WEB_SEARCH_ENDPOINT` | url string | brave/tavily → 各自官方 API;searxng → 无默认 | brave/tavily:落官方 endpoint;searxng:**首次真实工具调用**时抛 `WEB_SEARCH_ENDPOINT (the SearXNG instance URL) is required for the searxng provider`(`searxngSearch` 的首行守卫) | 不做 URL 形校验——写不成 URL 由 `new URL()` 抛出,同样落到"首次调用才现形"那条路径,不拒启 |
| `WEB_SEARCH_MAX_RESULTS` | number | `10` | 用默认 10 | 非数字/≤0 → 回落默认 10;是数字则向下取整;最终值再夹在 `[1,20]`(设 999 也被 clamp 到 20 —— `createWebSearchBackend` 里的 `maxResults` clamp) |
| `WEB_SEARCH_TIMEOUT_MS` | number(ms) | `10000` | 用默认 10000 | 非数字/≤0 → 回落默认;最终值下限夹到 1000ms(`createWebSearchBackend` 里的 `Math.max(1000, …)`) |
| `WEB_SEARCH_SEARXNG_PARAMS` | string(`k=v;k=v`) | 无(不铸键 = 不传 `extraParams`,交给 core adapter 自己的缺省) | 键整个不铸 | 一个 `k=v` 对都解析不出(没有 `=`,或 `k`/`v` 任一为空)→ 同缺席,整串忽略,不拒启不 warn(`parseSearxngParams`);env 侧值恒为字符串,不会触发下面 per-request 那个"数组被误当对象吸收"的边角(见 §9.5) |
| `WEB_SEARCH_PROBE_ON_BOOT` | boolean(仅认 `"true"`/`"1"`) | `false`(OFF) | 不探活 | trim+小写后不等于 `"true"`/`"1"` 的任何拼法(含 `"yes"`/`"on"`)一律当 `false`;探活失败(网络不通/后端拒绝)只 `warn`(`web_search_probe_failed`),**不拒启**——功能型能力缺席走降级,不是保护型旋钮(`main.ts:1028-1034`) |

**结论:七键无一在 boot 期拒启。** 与本仓其它安全轴旋钮(`MODEL_DEGRADE_ON`、思考档三键等,拼错即拒启)
姿势不同——WebSearch 挂不挂是**功能面**取舍,不是安全边界,七键统一走"回落/降级",不是"fail-closed 拒启"。
真正的配置错误(缺 key / endpoint 错)只在**首次真实工具调用**时才现形,以 tool_result 错误文本形式回给
模型,不是 HTTP 层错误、不影响 boot、也不使任务整体 `failed`(细节见 §9.5)。
- 结果是**不可信输入**:core 会 `delimitUntrusted` 围栏并**重新施加** `allowed_domains`/`blocked_domains`
  地板,所以 backend 遵不遵守 `opts` 是优化不是正确性要求 —— 换 provider(包括换成自建 SearXNG)
  不会削弱域名地板。
- 🔴 **`WEB_SEARCH_ENDPOINT` 的语义是「引擎可达的地址」,不是「用户机器上可达的地址」**
  (2026-07-31 与 cli 对齐的跨机契约句):壳(TUI/桌面/web)与引擎**可以不在同一台机器**。壳在用户
  笔记本上起的 localhost SearXNG,云端 worker 够不着 —— 所以「本地 SearXNG 兜底」这一档**只适用
  壳自 spawn 本地引擎的同机形**;壳连接远程引擎时该档整级跳过,由运维在**引擎侧**配 `WEB_SEARCH_*`。
  用户手填一个集中式 SearXNG 地址是合法形,照常放行。
- **per-request 覆盖 + 两车道优先级 + 错误形全表**:见 §9.5。

- **大工具结果落盘**(core 1.47/1.49):单条工具结果 > ~20000 字符时 core 把全文移出上下文、只留预览+ref,模型用 `read_tool_result` 按需分页回取。配了 TiDB 时自动用**durable `tool_result` 表**(跨副本 wake 仍能取回全文;否则 core 进程内默认 = 跨副本 wake 取不到→降级到预览,不崩)。`TOOL_RESULT_TTL_SEC`(默认 86400)按 TTL 回收(要 ≥ run 可恢复期)。启动日志 `toolResultStore` 回显 `shared(tidb)`/`in-process`。

**可选 — 成本计量 + 预算闸（core 1.37）**
```bash
# 每任务成本/token 上限(运维 CEILING)。调用方可在 body 传更小的 maxCostUsd/maxTokens,但会被夹到这个上限以下。
MAX_TASK_COST_USD=0.50   MAX_TASK_TOKENS=200000   # 0/不设 = 不限。超限 → 任务 failed + errorCode limits.max_{tokens,cost,turns,walltime}_exceeded
# 每 principal 跨任务累计成本配额(固定桶窗,非滑动窗)。某人桶内累计花费超顶 → 下一个任务被拒(429 + retry-after)。
MAX_PRINCIPAL_COST_USD=5.00   COST_QUOTA_WINDOW_SEC=86400   # 0/不设 = 不限;默认窗口 1 天
# ⚠️ 上面两组都是 **per-SLICE / per-任务** 的窗:一次 park→resume(审批、plan_review、抢占、预算切片)
#    会给引擎**重开一个满窗**。要给「整条 park/resume 链」封顶,用下面这组**跨片总额**(需 RESOURCE_SUSPEND=true)。
RESOURCE_SUSPEND_TOTAL_TOKENS=750000     # 整条链累计 token 总额;0/不设 = 不限
RESOURCE_SUSPEND_TOTAL_BUDGET_USD=12.50  # 整条链累计 $ 总额(可小数);0/不设 = 不限
RESOURCE_SUSPEND_MAX_SLICES=8            # 整条链最多几片(零进展 slice 循环的兜底);0/不设 = 不限
# 三根都是**部署级**旋钮(只读 env,调用方无从影响),坏值(负数/非数/计数轴写小数)**boot 期拒启**——
# 一个悄悄没生效的总额天花板与根本没配代价一样,而 operator 会以为自己封了顶。
# 降级到更便宜的模型(而非直接失败)。MODEL_DEGRADE_TO = 目录里的便宜模型名,两种触发可同时开:
MODEL_DEGRADE_TO=deepseek-v4-flash
MODEL_DEGRADE_AT_COST_FRACTION=0.7    # ① 近预算(1.40):累计成本到 0.7×maxCostUsd 切(需任务有 cost ceiling)
MODEL_DEGRADE_REACTIVE=true           # ② 反应式(1.39):主模型 rate_limit/breaker-open 时切(brain 级,最外层)
MODEL_DEGRADE_ON=rate_limit,breaker_open   # 可选,反应式触发器子集;词表=breaker_open|rate_limit|budget|server_error|last_resort
# ⚠️ core 5.1.0 起,不设 MODEL_DEGRADE_ON 时引擎默认全集**含 server_error 与 last_resort**
#    (主模型 5xx、以及兜底的一般 http 错误类也切便宜模型)。
#    不想要这两类触发就显式设回 rate_limit,breaker_open。拼错词 boot 拒启(不静默忽略)。
```
- 🔴 **思考档两键自 7.57.0 起是闭集拒启族**(此前拼错**静默丢**):`MODEL_DEFAULT_THINKING` 收
  `minimal|low|medium|high|xhigh|max`(llm-core `ThinkingLevel` 六档)加哨兵词 `off`(= 显式「本部署
  不设模型级默认档」,与不设同义);`MODEL_REASONING_EFFORT_LEVELS` 收这六档的**子集**(CSV,声明
  「本 openai 端点吃得下哪些档」,高档 clamp-down 不 422;`off` 不是档位,写在这里拒启并点破)。
  未知词 **boot 拒启并点名坏词**——与 `MODEL_DEGRADE_ON` 同律:`hight` 此前静默落成「无默认档」、
  `med,high` 此前静默窄成只剩 `high` 一档,而这根旋钮的全部用途就是宣告高档可用,静默落空 = 高档
  永远上不去。空串 / 纯分隔(`, `)仍与不设同义(不铸键,行为不变);anthropic 腿刻意不铸 compat
  (那条腿不吃 `reasoning_effort`),`GET /v1/config/catalog` 对它诚实回 `null`。
- 🔴 **`MODEL_THINKING_FORMAT`(7.69.0 新,同族第三根)**:这台网关按哪种拼法收「开/关思考」——
  `openai|openrouter|deepseek|together|zai|qwen|qwen-chat-template` 七词闭集(上 core
  `OpenAICompletionsCompat.thinkingFormat`),未知词同样 **boot 拒启并点名闭集**。缺席 ⇒ 不铸这一键
  (core 按 baseUrl / 模型 id 自行推断,行为逐字不变)。**真需求**:vLLM / Qwen 类**缺省开思考**的网关,
  只有 `qwen-chat-template`(`chat_template_kwargs.enable_thinking=false`)关得掉,而 `MODEL_EXTRA_BODY`
  穿不过引擎的 OpenAI 保留键表 —— 不设它,这类部署每一轮都白烧一段思考且读数上看不出来。它与
  `MODEL_REASONING_EFFORT_LEVELS` 铸进**同一个** `compat` 对象(两根旋钮同时设两键都在)。
- 🔴 **逐模型声明(目录腿):`models.models[].compat`**(settings-schema ≥1.10.0;7.69.0 消费)——
  同一张 `OpenAICompletionsCompat` 五键(`thinkingFormat` / `reasoningEffortLevels` / `maxTokensField` /
  `requiresReasoningContentOnAssistantMessages` / `supportsReasoningEffort`)可以**按模型**写在
  `config.d/models.json` 或配置控制面的目录条目上,逐键盖过上面两根 env 缺省(BL-8:没写的键仍继承 env)。
  **坏形/坏词/未知键 ⇒ 丢掉这条 `compat` 声明、模型本体保留**,并打一条 `model_compat_dropped`
  点名 warn(引擎回落到自己的推断 = 今天的行为);一条写坏的声明**不会**让这只模型或整套目录消失。
- 反应式降级 brain 包在**最外层**;fallback brain 用自己的凭据(decorator 清掉主模型的 per-call key 防外泄给别的 provider)。**坑**:`MODEL_DEGRADE_TO` 最好别和被限流的是同一网关/账号,否则反应式切过去照样撞同一个 rate_limit。
- **定价怎么设**:`model.cost` 来自 `MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE`(**USD per 1M tokens**)。**四键一个都不设 = 未定价**:成本读面**缺席**(`result.stats.costMicroUsd` 整键不出、`costBreakdown` 不产、`/v1/capabilities.pricingConfigured=false`),不是 `$0` —— 消费端应渲成「未知」。设其中任一键 ⇒ 四键在场(未设的按 0)。本地自托管(qwen)真的没有 per-token 外部花费时,请**显式**把四键设成 `0`:那是一次「声明免费」,读面照常发 `costMicroUsd:0` 且 `pricingConfigured=true`。云模型(如 review-gw 的 deepseek-v4-flash)必须设真价,否则 spend 无从计算。字段名是 `costUsd`,**非美元计价的网关要先折算**(如 DeepSeek 官方 CNY ÷ 汇率)。配置控制面管的模型走 `CenterModel.cost`(中心存价、不存 secret;名册行不带 `cost` = 该模型未定价,同上)。
- **成本天花板要有价才咬得住**:`MAX_TASK_COST_USD` / `MAX_PRINCIPAL_COST_USD` 量的是 `costUsd`,未定价的模型算不出成本 ⇒ 这两道闸**永不触发**。目录里一只带价模型都没有却配了天花板时,boot 会打一条 `cost_ceiling_without_pricing` 告警(不拒启);token 轴的 `MAX_TASK_TOKENS` 不受影响。
- 成本计量**自动开**:从 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 降级)。
- **近预算降级 vs 硬闸**:降级(`MODEL_DEGRADE_TO`,到 `atCostFraction` 切便宜模型)是**撑长**预算、任务仍完成(出口质量下降、发 `task.degraded` 事件可告警);硬闸(`maxCostUsd` 全额)仍在,切了便宜模型还超全额 → `limits.max_cost_exceeded` 停。
- **`METRICS_TOKEN`**(可选,只读):设了它,`GET /metrics`+`/metrics/summary` 接受**它或** `SERVICE_AUTH_TOKEN`。**全 fleet 设同一个值** → 管理端(sema-web,规划名 sema-admin)用**一个** token 拉所有 worker 的指标,**无需持有各 worker 的全权 `SERVICE_AUTH_TOKEN`**(不破坏 secret 边界)。即使外泄也只暴露指标(只读)。
- **`GET /metrics/summary`**(token-gated,同 `/metrics`):`/metrics` 的**精炼 JSON**——`{model, runsActive, tasks{status}, tokensTotal, costUsd, costUsdByModel, taskDurationAvgSec, brainFirstTokenAvgMs, brainCallAvgMs, cacheHitRateAvg, rateLimited, costQuotaRejected, budgetExceeded, limitCeilingAfterAnswer, degraded, cascade, verifications, councilRuns, toolErrors, httpErrorsByClass, httpErrorsByRoute}`(`httpErrorsByClass` / `httpErrorsByRoute` 自 **7.87.0**,additive:窗内 HTTP **失败**请求按状态码类聚合(键只有 `4xx` / `5xx`,2xx/3xx 不进;⚠️ `4xx` 含本仓给「客户端提前断连」铸的 `499`,SSE 长连上是常态)+ 失败最多的前 10 条 route 标签(`routeLabel` 低基数形,id 折成 `:id`,未命中路由表的落 `other`)—— 这两格是**不装 Prometheus 的部署**看见一场 404 风暴的唯一地方;两键**恒在场**,零失败时是 `{}`,不用缺席表示零)(`limitCeilingAfterAnswer` 自 7.71.0,按 `origin` 分桶的「答后撞顶」计数;与 `budgetExceeded` **总体不同** —— 前者每条引擎 run 都过,后者只由 HTTP 提交腿递增 —— 不要无条件相加)。给**轻量 fleet 看板**用(管理端 sema-web 的 fleet 页按 worker 拉、渲染卡片、按轮询算速率;**不用 Prometheus/Grafana**)。counter 是累计值、histogram 报均值。
- 预算闸是 core 强制的:`maxCostUsd` pre-call 估算(没花钱就拒)+ 流中途取消 + turn 边界(`limits.max_cost_exceeded`);`maxTokens` 超 → `limits.max_tokens_exceeded`(core 5.8.0 起 `budget.*` 前缀退役)。**review 网关**建议设 `MAX_TASK_COST_USD` 防单任务烧掉共享云 key。
- 🔴 **三道 $ 闸的分工(别拿进场门当中途停机用)**:三根旋钮名字都带 cost,但**判的时刻**和**能不能打断正在烧的那张单**完全不同。按「谁触发 / 什么时刻判 / 拒或停长什么样」对表:

  | 旋钮 | 谁触发、什么时刻判 | 拒/停的形 | 能打断在跑的 run 吗 |
  |------|------------------|-----------|--------------------|
  | `MAX_TASK_COST_USD` | 引擎(core)按**这一片任务**的累计花费判:首个模型调用前的 pre-call 估算 + turn 结束回查;**未开** `RESOURCE_SUSPEND` 时另有流中途投影取消(开了 resource-suspend 这一臂自动关,只在边界判) | 未开/不合格 ⇒ 终态 `failed` + `errorCode: limits.max_cost_exceeded`(HTTP 仍 200 / SSE `done` 帧带码);**开了 resource-suspend 且合格 ⇒ `suspended` + `resource_limit` 卡**(可续跑,不是失败)——消费方两种终态都要认 | ✅ 能,但只封**单片**:一次 park→resume 会重开满窗(见下条「per-slice 窗 vs 跨片总额」) |
  | `MAX_PRINCIPAL_COST_USD`(+ `COST_QUOTA_WINDOW_SEC`) | **HTTP 提交面**在受理请求**之前**读该 principal 桶内累计。挂门的是**任务/run 族**:`/v1/tasks`、`POST /v1/runs`、steer/resume/wake、workflows、leader、a2a,以及 park 后赎回时开的续跑腿(⚠️ 例外见下:operator-only 的记忆整合口不在此列) | `429` + `errorCode: limit.cost_quota_exceeded` + `Retry-After` 头 + 体 `{usedMicroUsd, limitMicroUsd, retryAfterSec}` | ❌ **不能**。跑起来之后不再回查:越顶那张单会**跑完**,天花板从**下一次提交**起生效 |
  | `USAGE_WINDOWS[].maxCostUsd`(§9.2) | 引擎在**每个 turn 边界**把累计花费记进跨任务账本再回查窗;提交面另有同店同键的快速门 | 基建合格 ⇒ `suspended` + `checkpointGate.reason=usage_window` + `resumeAfterMs`(等窗放行原样续跑);不合格 ⇒ `failed` + `errorCode: usage.window_exhausted` + `retryAfterMs`;提交面 ⇒ `429 usage.window_exhausted` | ✅ 能,且是唯一按**跨任务累计**花费中途停的一根(第一根按的是这一片自己的额度,管不到租户总账) |

  - **怎么选**:想给**单张任务**封顶 → `MAX_TASK_COST_USD`;想给**某租户按天/周限额、超了就不许再提新单** →
    `MAX_PRINCIPAL_COST_USD`;想让**正在烧的那张单**在租户总账超额时当场停下 → `USAGE_WINDOWS` 的
    `maxCostUsd`。后两根**可以同时配**(一个管进场、一个管中途),账本互相独立:提交面配额是 per-principal
    **固定桶**窗(见下),治理窗是 core 的跨任务账本(带 principal 计到各自的窗、匿名计到 GLOBAL 锅)。
    还有第四根 `RESOURCE_SUSPEND_TOTAL_BUDGET_USD`(下一条)——它约束的是**同一条 park/resume 链**的
    跨片总额,轴是「这条链」,不是「这个租户」,与上面三根不互相替代。
  - ⚠️ **最常见的误配**:只配 `MAX_PRINCIPAL_COST_USD` 就以为封住了「疯跑单」。它是**进场门**——一张已经
    受理的任务会一直烧到自己的 per-task 闸或跑完为止,期间没有任何一刻会因为该 principal 越顶而停;
    运维看到的是「天花板说不许花的钱花完了,下一次提交才 429」。要当场停就必须配治理窗的 $ 天花板
    (前置:模型**可计价**,否则任务门口就拒,见 §9.2)。
  - 记账口径:`MAX_PRINCIPAL_COST_USD` 的累计来自权威的整数 `costMicroUsd`(覆盖主任务 + 异步/council
    子任务,按 principal 归因);单副本部署是进程内计数,配了 SQL 后端则跨副本共享。
  - ⏱️ **窗形是「固定桶」不是滑动窗**(别按滑动窗估保护强度):内存腿从该 principal 的**首次记账**开锚,
    满 `COST_QUOTA_WINDOW_SEC` 后整桶清零重开;SQL 腿(跨副本)用**对齐桶**(桶号 = `floor(now / 窗长)`),
    桶界随时钟对齐。两种形都在**桶边界**清零 ⇒ 紧贴边界前后的两个桶可以在很短时间内合计花掉近两倍额度。
    ⚠️ **别用「把窗调短」当护栏**:额度不变而缩窗,边界突发仍是近两倍额度,长期允许速率反而抬高
    (每天 $5 改成每小时 $5,一天就成了约 $120)。要压边界突发,窗与额度**按目标速率同比调小**,
    或改用治理窗——`USAGE_WINDOWS` 的 `rolling` anchor 才是真滑动窗。
    ⚠️ 换窗长 = 换记账周期,而且**改后重启**才生效:内存腿换窗长即清当代累计;SQL 腿的桶号由
    `floor(now / 窗长)` 算出,新窗长**通常**落到不同桶号(= 从零记起),但两个相近的窗长在当下时刻
    可能算出**同一个**桶号,那一次换窗就会继续沿用旧桶的累计。要确保从零记起,换窗长时顺手清一次
    `cost_quota` 表里该 principal 的旧桶行。
  - 🚧 **不受这道门保护的花费**(如实登记,别把它算进保护范围):operator-only 的
    `POST /v1/admin/memory/consolidation/run`(一次整库蒸馏,量级 ~1e5 prompt token)既不过这道门,
    花费也**不入**该 principal 的桶。它不是一条 Runner 任务(自带的操作面记忆引擎直调驱动模型),
    所以上表三根 $ 闸**对它都不生效**,治理窗账本也收不到这笔。现阶段可用的手段:`OPERATOR_PRINCIPALS`
    名单收紧、给 consolidate 驱动席位挑便宜模型、以及网关/账号侧的限额。
  - ⚠️ **门键 = 发起请求的 principal**,而一条被跨租户 operator 恢复(`/decide`、`/plan_review`、`/preempt`
    续跑)的挂起腿,花费记到**卡带持久化的属主**。⇒ 属主已越顶、operator 没越顶时,这次恢复照样放行,
    钱记在属主账上。要让「属主越顶就不许再被人续跑」生效,配治理窗:它按**账本键**(挂起时那位属主)
    在恢复腿**进场**就回查,不依赖谁按的那次按钮。
- 🔴 **per-slice 窗 vs 跨片总额(别把前者当后者用)**:`MAX_TASK_TOKENS` / `MAX_TASK_COST_USD` / 任务墙钟都是 core 的
  **per-SLICE 窗**——引擎每次被调用都重铸它们,所以一次 `POST /v1/approvals/:id/decide`、`…/plan_review`、
  `…/resume`、`/wake` 续跑拿到的是**全新的满窗**;停顿前那条腿烧掉的量只在**跨片账本**上被扣。那本账 core
  一直在记(park 时铸、resume 时还原),但**没有总额就没有天花板**。所以一条被人反复批准续跑的任务,只靠
  per-slice 窗是封不住顶的——要封顶就配 `RESOURCE_SUSPEND_TOTAL_TOKENS` / `_TOTAL_BUDGET_USD`(以及
  `_MAX_SLICES` 兜住零进展循环)。前置:`RESOURCE_SUSPEND=true` + durable checkpoint 能力的 backend。
  - **两条总额是「冻结」的**:`_TOTAL_TOKENS` / `_TOTAL_BUDGET_USD` 在**首片**被写进 checkpoint 的账本,
    此后续跑重传无效(设计如此:一次 resume 不能刷新自己的额度)。⇒ 改这两根 env **不影响已经 park 的链**,
    只对新任务生效。
  - **`_MAX_SLICES` 不冻结**:引擎不把它存进账本,每片现读当期部署值去比账本上累计的片数。⇒ 改它会**立刻**
    作用于已 park 的链(调大=放宽、设 0=解除兜底)。这是部署级旋钮跟随当期部署的常态,但别把它误读成
    「和两条总额一样冻在链上」——两者的运维含义不同。
  - 🔭 **跑的过程中怎么看还剩多少**(7.48 起):配了总额的 run 在 **running** 期间,
    `GET /v1/runs/:id` 顶层多一只 additive 的 `crossSliceUsage`
    (`{totalTokens, spentTokens, totalBudgetMicroUsd, spentMicroUsd, maxSlices, sliceCount}`,三轴各自成对、
    上限没配那根就整对缺席;`$` 轴是 **micro-USD** 整数,显示端 ÷1e6)。**用途 = 提前介入**:此前只有爆窗
    那一刻(行翻 `suspended` + `resource_limit` gate)才知道逼近过。
    ⚠️ 三条读法纪律:① 读数是**下界**。**多数 running 态 poll 场景(异步 bg 循环、resume 续跑腿)在每个
    turn 边界都会把这一 turn 的用量落进 durable 账本**,故照旧「至多滞后一个 turn」;**唯一的例外是不带
    detach opt-in 的单次同步流**(`POST /v1/tasks/stream` 未带 `x-detach-on-disconnect`)——该跑形从 turn
    边界到最终 park/结算**全程不给这本账写一个字**(不是「终局才补账」),该读数在这条腿存续期间维持它
    开始前的值,直到同一任务上别的 durable 腿(异步/detach/resume)写入新账才会变。不含委派子代花费);
    ② **整键缺席不等于「没花钱」**——没配总额 / 非 running / poll 落到没跑过这条 run 的副本(记录是同副本
    best-effort,与 `msSinceLastActivity` 同族)/ 账本读不可用,四种都是缺席;后者计数在
    `fail_open_total{tag="server.runs.cross-slice-usage-unavailable"}`;③ 它是**给人看的预警面**,真正执法的
    是引擎账本 —— 别拿它做自动重试/取消的机器判据。
- **per-principal 累计配额**:用 `AsyncLocalStorage` 把 principal 透传到 cost tracer,所以**council/team 子任务的花费也算到发起人头上**。**配了 `SESSION_BACKEND=mysql` 时自动跨副本共享**(`cost_quota` 表,写后聚合的**原子自增** `micro=micro+delta`,对齐固定窗,最终一致——多副本花费 SUM 到一起、不丢增量);否则 in-memory per-replica 固定桶窗(首次记账开锚,单副本兜底)。启动日志 `costQuota` 字段回显 `shared(mysql)`/`in-process`/`off`。跨副本是最终一致(flush 间隔内峰值可能略超,由**硬 per-task `maxCostUsd` 兜底**)。
- **`RATE_LIMIT_RPM` 请求限流同样自动跨副本**(`SESSION_BACKEND=mysql` 时,`rate_limit` 表,与配额共用 `WriteBehindCounter`;启动日志 `rateLimit` 回显)。注意:写后聚合 = **软限流**(边界上短暂略超 OK,适合公平/热调用方防护);要**硬合规上限**得另走 CAS/原子计数,不靠写后聚合——和断路器跨副本同款权衡。

**可选 — 生成调参透传 + 退化打捞（core 1.59/1.60）**
```bash
# 生成参数直透网关(core 1.60 Model.extraBody)。frequency/presence penalty 从源头压退化循环——
# 是 core 退化检测(安全网)的预防。长链推理/council 易跑飞的部署建议设。
MODEL_FREQUENCY_PENALTY=0.5   MODEL_PRESENCE_PENALTY=0.3
MODEL_EXTRA_BODY='{"top_k":40}'   # JSON 逃生口(top_k / logit_bias 等;penalty 同名键会覆盖它)
                                  # ⚠️ core ≥5.15 起 max_completion_tokens 是保留键(config.extra_body_output_cap
                                  #    响亮拒)——输出上限请用任务面的 limits.maxOutputTokens,别从这里塞
```
- **brain 拥有的键永远赢**:`temperature`/`max_tokens` 等放进 `extraBody` 会被 strip + core 警告(`phase:"config"`),不会静默改;auth/content-type/version 头硬锁不可顶替(1.60 安全修复)。
- **必须静态**:`extraBody` 在 boot 时按固定 env 建一次(稳定键序),**不可逐任务变**,否则破前缀缓存。未设 → 请求字节级不变。
- **退化打捞**:模型尾部循环退化时 core 切断,任务 `terminal:{kind:"failed", code:"output.degenerate"}`,`salvagedOutput` 带回那一退化 turn 的文本,**尾部已由 core 在 brain 流层裁掉**(逐字节重复只留一份重复单元 —— 声明为**有损**归一:不丢唯一字节,但重复**次数**不保真,「输出 N 份」这类语义拿回来只有一份)。
  🔴 **调用方读法(core 官方配方,别拿 `terminal.kind` 三元代替)**:`const out = r.result || r.salvagedOutput || "";`
  —— 先读 `result`,空了才回落。理由:`salvagedOutput` 只在**一张七员闭集**的失败终局上在场(逐字取自 core 的 `SALVAGE_ELIGIBLE_TERMINALS`:`output.degenerate`、`limits.max_tokens_exceeded`、`limits.max_cost_exceeded`、`limits.max_turns_exceeded`、`limits.max_walltime_exceeded`、`env.lifetime_expired`、`usage.window_exhausted`),**闭集之外**的失败终局(模型/供应商错 `provider.error`、`conflict`、未铸码的失败……)它**整键缺席**,而最终助手文本仍在 `result` 里;闭集**之内**它又与 `result` 取自**同一条** final message 文本。两头都指向同一条读法:先 `result`。只读 `salvagedOutput` 会在闭集外的每一条失败路径上把真产出当成"没有输出"丢掉。penalty 是预防、这是兜底。

**可选 — OTLP/HTTP 指标导出(core 1.37 可观测)**
```bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318     # 设了才开;周期把指标 POST 到 <endpoint>/v1/metrics
OTEL_EXPORT_INTERVAL_MS=15000   OTEL_SERVICE_NAME=sema-server
OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer xxx        # 逗号分隔的 k=v(可选,如鉴权头)
```
- **零依赖**:不引 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` 溢出桶)。

**可选 — 成本分层 + `@-model`（core 1.24 role map）**
```bash
# 便宜模型分层(同网关、换 id):council 的 6 个 lens / 上下文压缩走便宜档,主任务与仲裁走主模型。
MODEL_ID=<your-model-name>  MODEL_CHEAP_ID=<your-cheap-model-name>
```
- 调用方可在 `objective` 里 `@<模型名>` 选模型,**只认已配置的名单**(`MODEL_ID` / `MODEL_CHEAP_ID`)——注入不了 `baseUrl`/`apiKey`;无 `@` → 默认模型。
- **`GET /v1/models`**(带 Bearer)→ `{models:[{name,id,provider,reasoning,vision}],default}`,只含名字/能力、**不含密钥** —— 消费方(如 OA)拿它做 `@` 自动补全下拉的数据源。
- 不设 `MODEL_CHEAP_ID` → 所有角色 = 主模型(行为不变)。
- **1M 双窗**(`autoCompactTokens`):`967000` 只发给 **claude-sonnet-5 系 id ∧ 窗 ≥1M**(CC 的 per-model 值,不是窗宽几何)。其他 ≥1M 模型要双窗触发点请显式配 —— 主槽 `MODEL_AUTO_COMPACT_TOKENS=<tokens>`、cheap 槽 `MODEL_CHEAP_AUTO_COMPACT_TOKENS=<tokens>`(**两槽各配各的**:主槽的值按主模型 id/窗声明,不会外溢到 cheap)。不配 → core 按整窗的 `W-33000` 平几何触发,boot 期发 `auto_compact_window_not_derived_1m` warn 点名槽位(`slot=main|cheap`)。⚠️ cheap 槽不设 `MODEL_CHEAP_CONTEXT_WINDOW` 时**继承主模型的窗**,主模型是 1M 则 cheap 也按 1M 计。取值范围:**必须低于该槽的物理窗**(CC 的形是 `窗-33000`)—— 高于物理窗时 **core 在取数处把触发窗夹回物理窗**(`resolveTriggerWindow`,自 core 5.60.0 起),即**这根旋钮变成 no-op**、几何回落成没配它的样子,并由引擎逐任务铸一条 `config.autocompact_window_clamped` 通告(受众 operator)。server 侧**不夹也不拒**(夹紧归 core 的取数处,一处一次),但会在 **boot** 就发 `auto_compact_tokens_above_context_window` warn 点名槽位、越窗值与 **`clampedTo`(core 会夹到的那个数)**—— 不必先派一条任务才知道旋钮白配了。⚠️ 7.57.0 之前这一段写的是「autocompact 事实上关掉、改由破坏性 guard 砍历史」,那是 core 5.60.0 之前的行为,已订正。

**可选 — 开发者模式 building blocks（core 1.43，默认中立）**
```bash
# 给指定角色挂「去品牌的通用编码提示词」CODE_AGENT_PROMPT（工程纪律：理解后改/最小复杂度/危险操作谨慎/验证后报告）。
MODEL_CODE_ROLES=default,subagent     # 不设=全中立;仅这些角色在「任务没自带 systemPrompt」时跑编码提示
```
- 经 `RoleSpec.systemPrompt` 挂在**角色**上，非开发角色保持中立、全局默认 `DEFAULT_SYSTEM_PROMPT` 不变；任务自带 `systemPrompt`(或客户端注入)时仍优先。
- **验证门**（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`(要过程可见性用异步形)。
- **记忆(文件记忆引擎,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 不提交)。
  **`MEMORY_PERSISTENCE_CAPABLE`(三态,7.14.0 起)**=**部署自述**「本机能不能把用户的『记住 X』落到持久处」,与请求键 `memoryWrite` 两轴正交(后者收紧本次运行,前者是 operator 声明;任一取否即只读方向)。缺省不设=引擎按 roster 自行推断;`true`=本部署有推断看不见的持久通道(自定义 writer / 记忆 MCP / 远程执行车道与记忆根共享挂载);`false`=强制只读披露并关掉文件工具往记忆根的写通道。不认得的词拒启。
  🔴 **`false` 自 core 5.27.0(7.14.0 提货)起还多一层——但只在 file 记忆引擎上**(`MEMORY_ENGINE_BACKEND` 未设 = 默认单机形):该会话是**受限会话**——materialize/搜索/harvest 一律按**已提交账**供给,记忆根下无事务背书的磁盘分歧既不收编也不供给,而是留盘 + 响亮点名(`restricted_divergence`,本服务把它计进 `memory_harvest_rejections_total{code}` 并 warn 一条),等下一次**非受限**会话走正常门收编。合法流程不受影响:用户手改、`git pull` 落下的删除照旧(延后,不销毁),并发的可写会话提交的变更算有事务背书。
  ⚠️ **`MEMORY_ENGINE_BACKEND=pg|tidb` 上没有这一层**(引擎的受限视图是 file 后端的实装):那两条腿的读侧本来就走库、盘上目录只是每任务的投影工作区,不存在「读磁盘即收编」的通道,所以也无洞可堵——`false` 在它们上仍然照常买到披露、写门与零收编 harvest 三件(与后端无关)。
  🔴 **降级前必须先停写排空(core 5.33.0 / server 7.21.0 起,运维 BREAKING)**:file 记忆后端的账本升到 **schema v2**(slug/scope rebind 与跨 scope transfer 改成 journaled 事务),而 **v2 journal 被 ≤5.32 的旧读者响亮拒**——这是**有意的 fail-closed**(拒启比静默读半张账好)。⇒ 要把服务降回 <5.33 的镜像:①**先停写、把在跑的记忆写排空**;②按 core 设计稿 §3.6 用 `transfers.jsonl` + journal 回放清账;③**再**降版。**排空这一步没做就降级 = 数据面响亮拒,不是静默丢**。`MEMORY_ENGINE_BACKEND=pg|tidb` 的部署不受此条约束(账本在库里,不走 file journal)。
  ⚠️ **口径别混**:「受限」是**会话级的这一个声明**;请求键 `memoryWrite:false` 铸出的只读**平面**(以及 org 层默认只读、双根非写面)**保持原样的 adopt-on-read**——它只是不 harvest,照旧看得见盘上的新内容。
  ⚠️ **core 5.26.0 起,remote 执行车道上的 `# Memory` 写指令被撤**(沙箱手带够不着 host 记忆根;读与注入不变)。**判据是「`REMOTE_EXEC` 有没有设」,不是那个旗**——凡设了 `REMOTE_EXEC`(含 `host`)且这次任务真有记忆面,写指令就被撤。因此被打到的**不止**显式 `MEMORY_ENGINE_REMOTE_LANE=allow` 的部署,还包括:① `MEMORY_ENGINE_BACKEND=pg|tidb` 的 DB 记忆面(它不受那个旗管辖——旗只管 file 引擎,库是持久真身,任何车道上都照常点亮)= 最常见的云形;② `REMOTE_EXEC=host` + file 引擎(文件平面就在本机,但 core 的执行环境判决仍是 remote)。若该车道确实够得着记忆根,`MEMORY_PERSISTENCE_CAPABLE=true` 是官方恢复路径(启动日志 `memory_remote_lane_write_instruction_dropped` 会点名这一条);够不着就表 `false`,让只读状态对模型说明白。
  另:记忆**没有写工具**,模型是用普通文件工具往记忆根写的——所以记忆根必须落在任务的 fs 授权边界内。结构性够不着时启动会 warn 并给出三条改法(把根挪进 workspace / 经 `additionalDirectories` 授权 / 声明 `MEMORY_PERSISTENCE_CAPABLE=false` 让披露诚实)。⚠️ **「挪根」用哪个旋钮按记忆后端分腿**:file 引擎是 `MEMORY_ENGINE_DIR`;`MEMORY_ENGINE_BACKEND=pg|tidb` 的根是 `<数据根>/memory-work`(数据根 = `LOCAL_DATA_ROOT`/`AGENT_DATA_DIR` 族),那两条腿**不读** `MEMORY_ENGINE_DIR`——启动 warn 的文案自己会按腿指对旋钮。
- **记忆检索的向量面(embedder,7.14.0 起)**:记忆检索有三档——`lexical`(词面 jaccard)/ `portable`(库内存向量,进程内算余弦)/ `native`(库侧向量算子)。⚠️ **没有档位旋钮**:档位是「记忆后端上限 × embedder 在不在场」**推断**出来的结果(显式选档只会造出「选了 native 却没 embedder」这类矛盾态)。**配上 embedder = 唯一的开法**,而且只在 `MEMORY_ENGINE_BACKEND=pg` 上有意义(tidb 记忆后端 v1 是词面档,file 引擎没有 embedder 接缝——两者配了 `MEMORY_EMBEDDER_*` 一律**拒启**点名)。不配 = 保持 `lexical`(默认姿态,零 warn)。

  | env | 缺省 | 说明 |
  |---|---|---|
  | `MEMORY_EMBEDDER_ENDPOINT` | 缺省 | OpenAI 兼容服务的**基址**(`https://host/v1`)或**完整**端点(`https://host/v1/embeddings`),两种写法都收(query 原样保留,Azure 形可用)。必须是 http(s) 合法 URL 且**不得带 `user:pass@` 凭据**(会被传输层错误/日志/记忆冲突文案反复复制;要 auth 走下面的 API_KEY),否则拒启 |
  | `MEMORY_EMBEDDER_MODEL` | 缺省 | 模型名,原样进请求体 `{model, input}` |
  | `MEMORY_EMBEDDER_DIM` | 缺省 | 声明维度(**正整数**,如 `1024`)。必须与模型真实维度一致 |
  | `MEMORY_EMBEDDER_TIMEOUT_MS` | `30000`(`[1000, 300000]`) | 单次 embed 的活性上限。挂死的端点会把整条记忆写入路径吊住 |
  | `MEMORY_EMBEDDER_API_KEY` | 缺省 | 可选 Bearer(自托管 TEI/vllm/ollama 通常不需要;公有云端点需要) |

  ⚠️ **半配 = 拒启**:上表前三键是一个整体,少任何一件都启动报错并**点名缺哪几键**。静默忽略的后果不是「功能缺席」而是「operator 以为向量面开着」——检索照常出结果(词面档),差异只在答案质量里,零日志。同理:只配 `TIMEOUT`/`API_KEY` 而三键不全、或 `MEMORY_ENGINE=off` 却配了 embedder,都是死旋钮,一律拒启。
  ⚠️ **维度守卫是拒启/抛错,不是 warn**:向量列写错维度是**静默毒库**——本仓 pg 记忆表的 embedding 列是 `jsonb`(无维度约束),错长度向量既不会让 DB 报错,也不会让检索报错(那些行只是悄悄退回词面档)。所以坏 `DIM`(非正整数)启动即拒;运行期端点返回的向量长度与 `DIM` 不符,embedder **直接抛错**,绝不截断/补零。换 `MODEL`/`DIM` 的**清空由指纹门自动做**(见下条),不必手动清。
  ⚠️ **embedder 出故障的方向**:端点 5xx/超时会让当次记忆**写入**报一条 `io error: …` 冲突(不写坏向量),检索腿则退回词面档继续服务——即「坏了退回 lexical」,不是「坏了写坏数据」。故障有专门信号:metric `memory_embed_failed_total{backend}` + 日志 `memory_embedder_failed`(只看 PatchReport 里的冲突会把供应商故障误读成并发改动)。
  ⚠️ **只对新写入的条目生效(没有回填腿)**:开启前就存在的记忆条目 embedding 列是空的,只有被再次写入时才会补上向量;这些行在检索里落**词面档**。🔴 **词面档不是「排名靠后」,是有门槛**:与查询**零词交集**的行被直接**丢出结果集**(词面距离在无交集时无定义,检索当场跳过该行),而带向量的行有余弦读数可用、**不会被这道门丢掉**(前提是 embedder 当时也把**查询**嵌出来了;embedder 故障那一刻查询侧拿不到向量,整轮检索退回词面档,于是零交集的行连同它们一起被丢)。所以没有向量的行对**改述 / 同义 / 跨语言**这类查询**检索不到**——那恰恰是向量档买的东西。要立刻全量生效,得自己重写一遍语料。
  🔴 **换 `MODEL`/`DIM` 的行为:下次 boot 自动清空向量列(embedder 指纹门,7.15.0 之后的下一个发布起)**。本服务在 `agent_memory_engine_meta` 单行元表里记住当前 embedder 的**指纹** `{model, dimensions}`(明文,便于排障;**endpoint 不进指纹**——换域名/代理不换语义空间)。每次启动比对一次:
  - **相等** ⇒ 零动作、零写;
  - **不等 / 元表还没有这一行而库里已有向量 / 那一行读不出来**(手改、回滚残留)⇒ **先把 `agent_memory_engine_entry.embedding` 整列清成 NULL,再写新指纹**(次序不可换:反过来若中途崩溃,新指纹已记而旧向量还在,此后每次 boot 都判「相等」,残留永不复检);
  - 启动日志 `memory_embedder_identity_changed` 带**旧值/新值/清了几行/耗时 ms/原因**(`changed` | `unattributed` | `unreadable`),首次记录指纹是 `memory_embedder_identity_armed`;比对腿**自身失败一律拒启**(吞掉 = 门在场却没跑成)。⚠️ 这条行按「identity 变了」发,**不是**按「清了几行」发:当时库里恰好没有向量可清(上一轮已清过 / 缺席窗内被写成 NULL)照样发,`cleared: 0` —— 换 embedder 这件事在启动日志里不该无痕。真正零响亮的只有「相等」那一档。⚠️ **这句承诺以「进程活到把日志发出来」为界**:清列与写元表是两条各自提交的语句,日志在两步都完成之后才发,所以进程恰好在清完之后崩溃(或被杀),下次启动会按当时的真实状态重判(报「首次记录指纹、没有向量可清」,或者元表已写完就直接判「相等」不发声)——**那一次清掉了多少行会从日志里丢失**。数据面不受影响(次序钉保证的就是这个:向量已 NULL、不会残留旧空间),丢的只是审计行;要精确到行数的审计请以 DB 侧的变更记录为准。
  - 清空**只丢可再生的向量缓存**(正文/frontmatter 分毫不动);清完**不回填**,行被下次写入时自然按新模型重嵌,期间检索退回词面档。🔴 **代价按上面那条读**:退词面档 = 与查询零词交集的行**检索不到**(不是排到后面),所以清空之后、重嵌之前,那些行对改述/同义/跨语言查询是**不可达**的。⚠️ **误配代价诚实说**:把 `MODEL` 改错一次再改回来,向量**不会自动回来**——只随行被再次写入才重嵌;对写完就不动的记忆库,误配一次 = 向量面实质长期缺席(正文零损失,检索退 lexical,零交集查询够不着)。要立刻恢复只能自己重写一遍语料(或接受词面档)。
  - ⚠️ **换 embedder 请停机换:先 `scale 0`,再起**。指纹门管的是 **boot 面**;**滚动升级窗**里新副本清完列之后,仍在服役的旧副本还在按**旧**模型写向量,滚动结束后表里混着两个空间而元表已是新指纹 ⇒ 此后恒判「相等」,门再也不会复检。这是纪律面的残余,不假装根治(行级 embedder 记账列可结构性根治,超出本件射程)。滚动事故后的补救 = 手动清一次:`UPDATE agent_memory_engine_entry SET embedding = NULL WHERE embedding IS NOT NULL;`(同一条也可用于「误配后立刻重置」)。
    🔴 **同族的第二格:回滚到指纹门之前的版本**(7.14.x / 7.15.x —— 有 embedder、无指纹门)。那些版本照写 `embedding` 列却**根本不认识这张元表**,所以「装本版(元表记 B)→ 回滚到旧版跑一段(往列里写 A 空间向量,元表纹丝不动)→ 再升回本版、配置仍是 B」这条路走完,元表与配置都说 B 而列里躺着 A 空间向量 ⇒ 此后恒判「相等」,门再也不会复检。**跨门回滚与滚动窗要做同一件事**:回滚之前(或重新升级之后)手动跑上面那条清列 SQL,或干脆 `DELETE FROM agent_memory_engine_meta WHERE meta_key = 'embedder_identity';` 让下次启动按「无主向量」保守清一次。
  - **没配 `MEMORY_EMBEDDER_*` 时门整个不装**(元表不动,旧指纹保留),启动日志 `memory_embedder_identity_absent` 说明:缺席期间**被改写过**的行,其 embedding 本来就被写成 NULL,所以「配置回归同一 identity 后向量续用」只对缺席期间**未被触碰**的行成立。
  - 残余:同名模型在不同供应商若其实是不同实现(自托管 finetune 撞名),指纹辨不出——明知异实现时改 `MEMORY_EMBEDDER_MODEL` 名,或手动跑上面那条清列 SQL。`MEMORY_ENGINE_BACKEND=tidb` 无向量面(v1 词面档),没有这张元表也没有这道门。
  📋 **档位对运维可见**:启动日志 `memory_engine_enabled` 行带 `vectorMode` 字段(取自后端实例的真值,不是配置推断)——`lexical` 在跑就必须看得见;配了 embedder 时同行还带 `embedderModel`/`embedderDim`,换模型这件事在日志里留痕。
- **org 记忆准入(7.0.0 起 BREAKING)**:`org:*` 记忆 scope 分**两个来源**——部署自证
  (env `MEMORY_SCOPE` 的 org 形 + **单用户部署**的 `projects[].defaultScopes` org 键)直通;**多租户**
  部署里由调用方 `projectId` 选中的登记簿 org 键算 **request 来源**,必须拿到授权目录的逐 principal
  授予才准入,拿不到一律 fail-closed 拒(纯读外泄面:projectId 只过形状门不过授权)。目录源**三态单选,
  授权面不双源合并**:config-center 远程腿(per-principal `orgMemory` 段)> `MEMORY_ORG_DIRECTORY_JSON`
  静态表 > 缺席(request 来源的 org scope 恒拒)。同一个目录也是 `GET /v1/memory/export` /
  `POST /v1/memory/sync/:scope` 的 `org:` 属主门真源(写面另需条目 `write:true`);拒绝形 = 终局
  `memory.admission_denied`(403)/ 瞬时 `memory.admission_required`(503,带 `retryAfterSec`)。

| env | 缺省 | 说明 |
|---|---|---|
| `MEMORY_ORG_ADMISSION_MODE` | `enforce` | `enforce`=判决即结果;`audit`=**运维诊断位**——准入面判决照算、拒绝降为日志+metric,零行为变化(不整拒、不窄化写面);`/v1/memory/export`、`/v1/memory/sync/:scope` 的 `org:` 属主门在 audit 下**逐字保持收编前的 operator-only**(未验证的目录不得开数据面)。两模式都要求目录 client 在场 |
| `MEMORY_ORG_DIRECTORY_JSON` | 缺省 | 单机形静态授权表 `{"<principal>":{"org:acme":{"write":true}}}`。**启动期整表校验,坏表拒启动**。有 config-center 时被忽略(center 胜 + warn) |
| `MEMORY_ORG_GRANT_TTL_MS` | `60000`(`[1000, 3600000]`) | 授予/负结果的缓存 TTL = 「有界 LKG」:**新任务/新 resume 腿**的准入判决滞后 ≤ 此值;**在跑任务不受吊销影响**(判决点在 prepare 期) |
| `MEMORY_ORG_UNAVAILABLE_BACKOFF_MS` | `10000`(`[500, 600000]`) | 目录取不到之后的退避窗(只用于取数失败臂,不用于负结果) |
| `MEMORY_DELEGATION_EVIDENCE` | 缺省(=引擎自缺省 `static-face`) | **委派臂的证据标准**(core 5.45.0 起)。`static-face`=今日行为:委派的 attestation 缺失/未知**且**静态工具面够得着外部内容 ⇒ 标记本会话记忆为已污染(可能性即暴露)。`attested-only`=**只**豁免那一条静态面标记,且**真的豁免了一次**才响亮通告(`engine_notice` 的 `memory.delegation_static_mark_waived`,每个 prepared leg **至多**一次);送达 `external` attestation 照标、非委派的污染类工具照标、委派工具自身被分类为污染的照标。⚠️ 通告**不是 leg 计数器**:不含委派的 leg、污染面没挂载的 leg、送达了 attestation 的委派都不发 —— 看不到通告的常态含义是「没有可豁免的事」,不是「配置坏了」。坏值**启动期拒**。**部署席 only**(TaskSpec 无同名键,任务/受治脚本无法据此放松部署)。⚠️ **已接受的代价**:`attested-only` 下**后台**子代的真实外部接触不标记本会话(其内容经 TaskOutput / 任务通知注入 / AgentTranscript 摘要回流,三条都不带 attestation);异常收尾的前台子代同理。已标记的会话永不回滚清洗。运维读面 = `GET /v1/diagnostics/wiring` 的 `memoryPosture.delegationEvidence`(`null`=本部署未设,引擎自缺省) |
| `MEMORY_PROVENANCE` | 缺省(=引擎自缺省 `carry`) | **记忆 provenance 总开关**(core 5.46.0 起)。一个会话被判为**已暴露**之后,它的记忆写怎么处置。`carry`=引擎缺省:**普通**记忆写照常提交,但带上引擎铸的 `origin` 标记(随条目走后端/同步/导出包);指令形文件(feedback / pinned / triggers / applies-when)仍被扣下隔离;派生索引的会话散文照回滚;内容扫描门原样跑(**标记不是豁免**)。`off`=此前的行为:不铸 origin 标记,已暴露会话的 harvest **一条都不收**(整体隔离候人审)。⚠️ 已提交的标记在编辑时照样带下去 —— `off` 停的是**铸**,从不抹掉已记的事实。与 `MEMORY_DELEGATION_EVIDENCE` **正交**(四种组合全合法):那一键决定**什么时候**判会话为已暴露,本键决定判了之后**对写做什么**。坏值**启动期拒**(`OFF`/`false`/`none` 这类「看起来像关掉」的写法一律不折成缺省 —— 静默回默认 = 一台自认为「暴露会话零收录」的部署其实在照常收录)。**部署席 only**(TaskSpec 无同名键,任务/受治脚本无法据此改姿态);**不冻进 checkpoint**(resume 腿跟当前部署配置走)。运维读面 = `GET /v1/diagnostics/wiring` 的 `memoryPosture.provenance`(`null`=本部署未设,引擎自缺省) |
| `MEMORY_CAPTURE_POLICY` | 缺省(=引擎自缺省 `open`) | **记忆采集 opt-out 的部署姿态**(core 7.0.0 起)。任务可带 `memory.capture:"off"` 声明「本会话不进长期记忆」(一次性,resume 恒不采集,无反向拼写),本键决定怎么裁那条声明:`open`=按面值生效,**只有**该 principal 的显式 `false` verdict 拒(403 `memory.capture_optout_denied`),resolver 故障**放行**+披露(隐私轴 fail-safe:错拒=记了用户明说不要记的会话,不可逆);`governed`=verdict **必答**(混合车队:部分用户强制留存),故障/缺席**拒跑**(fail-closed);`capture-required`=全体声明拒(一行配置)。verdict 源**两条**(析取):①SQL 店后端上的授权表 `memory_optout_grant`(三层折叠:per-principal 行 → 部署缺省行 → 代码缺省 allow;管理面 `GET|PUT /v1/admin/memory-optout`(PUT=部署缺省行)· `PUT|DELETE …/:principal`,operator-only,能力位 `memoryOptOutGrant`,零缓存写后即生效);②非 dry-run 的 config center 在该 principal 的 entitlement caps 里发 `allowMemoryOptOut`。🔴 **`governed` 而两源皆无 ⇒ 启动期拒启**(无源 = 每个声明都被拒且没人知道为什么;接 `DB_BACKEND=mysql|pg`、接非 dry-run center,或改姿态)。⚠️ **center-only 源的语义**(只配了 center、没有 SQL 授权表):`allowMemoryOptOut` 在 entitlement schema 里是**可选键** —— center 不发它 = 该 principal 的 verdict **缺席** = `governed` 下每次 opt-out 恒拒(403 `memory.capture_optout_denied`),而 worker **照常起动**;这一形部署级证不死(per-principal 授权只有运行期才知道),所以 server 两处响亮:启动期一行 `memory_capture_governed_center_only_source`、运行期首次真缺席一行 `memory_capture_governed_verdict_absent`(每进程一次,纯披露不改判)。要确定性 verdict 就接 SQL 授权表(它三层折叠后恒答显式 boolean)。坏值**启动期拒**(`Governed`/`required`/`off` 一律不折成缺省:两种姿态的故障极性**相反**)。**部署席 only**;**不冻进 checkpoint**。运维读面 = `GET /v1/diagnostics/wiring` 的 `memoryPosture.capturePolicy`(`null`=本部署未设)。记录载体(core 7.0.2 起):SQL 后端(`DB_BACKEND=mysql\|pg`)上记录落 `session_capture_optout` 表(跨副本存活,随中央 schema 自建零旋钮)——**远端车道(e2b/k8s)的 `capture:"off"` 声明自此真持久**;`DB_BACKEND=local` 自 7.71.0 起在记忆引擎接线时供 core 的控制面文件三腿(钉在本部署记忆引擎那只控制面目录上,按记忆平面取用)⇒ 单机默认部署(`CONFIG_PROVIDER=local`、`REMOTE_EXEC` 未设)上声明与中途翻转 verb 从 409 转为真受理(此前该 409 在这一形上是结构性的:core 判「远端」看的是执行环境的接口形,host 车道同被判 remote);记忆面暗的部署本席位缺席而 core 也不走采集腿。今天仍答 409 `config.memory_capture_unsupported` 的只剩把记忆引擎接了却没给任何店的自建装配形。用户面四口:提交键 `memoryCapture:"off"`(声明形)· `POST /v1/runs/:id/memory/capture-optout`(live 中途翻转,owner 门)· `POST /v1/sessions/:id/memory/erase`(所有者自助抹除,select 硬限本会话)· `GET /v1/sessions/:id/memory-status`(状态读面,owner 门,五可选键诚实缺席)。契约全文 `docs/ASSISTANT-WIRE-CONTRACT.md` §12 |

  ⚠️ **拒启动**:多租户 + 记忆面点亮 + `projects[].defaultScopes` 里有 org 键 + 目录源缺席 ⇒ 启动报错
  并点名 projectId(该部署的每个此类请求都会在 prepare 期整拒,响亮拒启动比静默全拒服务诚实)。
  ⚠️ **回滚脚枪**:回滚到「无 config-center」模板前先清掉残留的 `MEMORY_ORG_DIRECTORY_JSON` —— 否则
  它作为 operator 自证通道**复活旧授权**(详见 `docs/DEPLOY-PREREQS.md`)。

**可选 — 模型级联 cascade（core 1.45，opt-in）**
```bash
# 模型阶梯(便宜→强)。请求体带 cascade:true → 先跑便宜档,门没过(默认=没 completed,即便宜档失败)就升级到强档。
MODEL_CASCADE_LADDER=deepseek-flash,deepseek-pro   # 目录里的模型名,cheapest→strongest;不设=cascade 不可用
```
- 结果带 `cascadeOutcome:"passed"|"exhausted"` + `escalated`/`finalRung`/`attempts[]`。`/metrics` 加 `cascade_total{outcome}`。
- **每档用自己的上游 key**:从配置控制面模型的 `apiKeyEnv`(env 变量**名**,非密钥值)解析——同一网关下不同模型/账号各用各的 key,没配 `apiKeyEnv` 的模型回落到网关 key(`MODEL_API_KEY`)。`baseUrl` 仍由 brain 层统一持有(非目标)。启动日志 `perModelKeys=N` 报有几个模型带了自己的 key。

**嵌入形契约(1.309+;桌面宿主把引擎作为依赖内嵌启动的正门)**
- 入口:`import "@sema-agent/server/main"`(exports 正门;此前宿主只能用 node_modules 路径字符串定位
  `dist/main.js`,那是未承诺的内部路径)。语义承诺三条:**import 即 boot**(副作用模块形,无需调用);
  **不读 `process.argv`**(宿主可用 argv 传自己的标记,`ps` args 判据不受干扰);`./package.json` 可读。
- `/health` 身份字段承诺:`pid`(= 引擎进程)、`startedAt`(**7.67.0+**,= 引擎进程起点的 epoch ms,
  一次铸)与 `dataRoot`(= 生效数据根,解析恒回退 `~/.ai-agent`,与 DB_BACKEND 无关)**恒在**——宿主用
  它们验证「这个端口上的 /health 是不是我起的那个引擎」。`startedAt` 变了 = 这个端口后面换了一条命
  (另一个宿主重启了共享引擎):壳据此把「引擎换代」当**一等事实**处理,不必等某个带鉴权的请求先撞一次
  401 才发现(`/health` 无鉴权,心跳恒绿)。⚠️ 只给这一个数:service token 本体与代际计数**不上**这一面。
  钉:`test/health-identity-contract.test.ts`。
- `/health` 活体 store 探测(5.14.0,SQL 后端专属 additive):后台探针(`STORE_PROBE_INTERVAL_MS`,
  默认 15000;0=关;负/非数启动响亮拒)缓存一份 DB 真往返结果,`storeProbe:{live,ageMs,error?}` 在
  探针接线时恒在,顶层告警键 `storeLive:false` 只在死时出现。**status 保持 "ok"**(liveness≠readiness
  ——DB 死不是进程死,摘流语义留给读键的编排器/LB)。local/memory 部署形状不变。
  `error` 是**闭集词**(7.36.0 起):`probe_timeout` / `connection_refused` / `connection_reset` /
  `host_unreachable` / `dns_failure` / `network_timeout` / `auth_failed` / `connection_limit` /
  `probe_failed`(未识别)。**驱动原始异常 message 不上这一面**——`/health` 免鉴权且缺省全网卡监听,而
  连接类异常惯于回声整条 DSN(`mysql://user:pass@host:3306/db`)。要读全文去**日志**:探针在
  live→dead 翻转拍打 `store_probe_dead`,`error` 字段是原文。
  钉:`test/health-store-live.test.ts` / `test/store-live-probe.test.ts`。
- 数据驻留提示:`DB_BACKEND=local` 下显式 `SESSION_BACKEND=memory` 会被收编为 **durable(local)**
  (1.292+ 裸 boot 默认 durable;/health 的 `sessionBackend` 报 `durable(local)`)——session 行落盘在
  数据根下,清数据/隐私预期要按「sessions 在 engine-data 里」来做,不要按「只在内存」。
- **任务清单(TaskCreate/TaskList 家族)的持久边界**:清单按**会话**分区,同一会话
  跨 turn/跨 resume 连续,跨会话互不可见。配了 SQL 后端(`SESSION_BACKEND=mysql` / `DB_BACKEND=pg`)
  时落 `task_list_meta`/`task_list_item` 两表 ⇒ **跨副本、跨重启都续得上**;**没有 SQL 后端的形态
  (`local`/纯内存)清单只活在进程内 —— 重启即丢待办**(同时活跃会话超过 4096 时最老的会话清单被
  LRU 回收)。要跨重启保住待办就配 SQL 后端;这与该形态下其余会话态设施(per-session cwd 等)是同一
  条边界,不为它自造持久性。**删除权**:`DELETE /v1/sessions/:id` 与托管留存 sweep 两条腿都把清单
  行与编号高水位一并抹掉(同 anchors/附件/快照的级联)——会话 id 可被重新认领,残留就等于把上一位
  主人的任务标题与编号进度交给下一个化身。
- 多实例边界:同一数据根同时只允许一个实例(BootLock 独占,第二个进程拒启;死主自愈含 SIGKILL)。
  core 5.54.0 起拒启错误带**可分派 code**(`FileStoreLockError`,message 点名占用方 pid),运维按码
  三动作:`store.dir_in_use`=另一个活进程持有该目录 → 停掉它(或换数据根/迁 SQL 后端);
  `store.dir_claiming`=另一进程正在接管崩溃者留下的锁 → **瞬态,稍候重试**(接管方要么完成要么
  自己也被收割);`store.lock_unreadable`=锁文件坏形(无 owner 可解析/嵌套接管残留)→ 人工处置,
  message 会点名要删的文件,**不要盲删数据根下其它东西**。server 刻意不把这族错**吞掉**(fail-fast:
  两进程共用 file 数据目录是禁止形,静默容忍比拒启更坏)。
  ⚠️ 自 7.93.1 起 `store.dir_in_use` **这一码**多一道有界等待(见下面 `STORE_LOCK_WAIT_MS` 那条):
  拒启形与文案一格不变,只是拒之前会先在窗内反复重取;另两码(`dir_claiming` / `lock_unreadable`)
  与非锁类失败**一秒都不等**,原样抛。
  `DB_BACKEND=memory` 并非全无盘:workflow 相关账本仍挂数据根下——多个 memory 形引擎**不要共用**
  数据根/HOME(1.309 起并发 boot 不再拒启,但账本内容级共享仍不受支持)。
- **`STORE_LOCK_WAIT_MS`(缺省 `60000`,单位 ms)—— 撞上活持锁人时等一会儿再拒**:
  `DB_BACKEND=local` 的数据根锁被**还活着**的持有者占着时(最常见的成因:壳自己 spawn 引擎,用户关掉壳
  又立刻重开,旧引擎正在 `DRAIN_GRACE_MS` 的优雅排空 + 停机收尾里,锁要到收尾链尾部的 `backend.close()`
  才归还),新实例在这个窗内**反复重取**数据根:拿到就照常起动(启动日志一行
  `store_dir_lock_wait{ownerPid,dir,waitMs}`,**只一行**),到窗仍拿不到才拒启 —— 拒句是原来那一句,
  额外写明 `waited Nms`(运维据此区分「当场拒」与「等满了才拒」)。等待发生在 `listen` **之前**,语义是
  「启动慢了 N 秒」而不是「一边服务一边等」。
  🔴 **方向是多等,不是放行**:窗只决定「拒之前等多久」,任何情形下都不会让第二个写者进同一个数据根。
  代价 = 两个真独立部署误指同一目录时,那句「被 pid N 占着」要等满窗才出现(编排面的 liveness 探针
  initialDelay 请按这个窗放宽)。要立刻响 ⇒ `STORE_LOCK_WAIT_MS=0`(逐字回到 7.93.0 的当场拒)。
  上界不设(愿意等多久是部署自己的事);负值夹到 0 并在启动日志点名。只有 file 后端读它。
  🔴 **两条取数据根的路是同一条腿、同一只旋钮**:HTTP 服务的 local 臂与 `run-local`(一次性 CLI)共用
  同一只等待腿 —— **不为前台形留第二套语义**。前台形要「立刻告诉我谁占着」就设 `STORE_LOCK_WAIT_MS=0`,
  那是文档化的通道,而不是两条路各有一套默认。
  ⚠️ **裸 boot(没有显式 `DB_BACKEND`)那一形的时序也跟着变**:它的锁冲突走的不是拒启而是既有的
  「退回内存并 warn」降级臂(`store_db_unreachable_fallback_memory`),所以第二只实例此前**当场**降级、
  现在要**等满窗**才降级 —— 方向仍是好的(窗内拿到就用上真数据根,而不是静默丢掉持久面),但它是一次
  真实的启动时长变化。显式 `DB_BACKEND=local` 不走降级臂(拒启),`0` 关掉两形的等待。
  三问成文见 `docs/DEPLOY-PREREQS.md`。
- **`RUN_STORE_STRICT_HYDRATE=on|off`(缺省 `off`)—— file 后端 run 账本的 fail-closed 启动门**:
  `DB_BACKEND=local` 的 run 账本在 boot 时把 `<数据根>/runs/` 列一遍重建内存索引。那次 `readdir` 失败
  (权限、挂载丢失、路径被换走)时**缺省行为是照常起动**,只留一行 `file_run_store_hydrate_unreadable`
  的 **error** 日志(带 runs 目录绝对路径 + 异常文案)与 `fail_open_total{tag="server.run-store.
  file-hydrate-unreadable"}` 计数 —— 但那之后**每一次 `GET /v1/runs/:id` 都会 miss**,而「miss」在 wire
  上与「本来就没有这条 run」完全同形。把 run 账本当真账本用(对账、审计、外部系统按 taskId 回查)的
  部署可以设 `on`:此时那次失败**拒启**,遗言仍带绝对路径与异常文案,拒因给三条出路(修权限/挂载、
  把 `LOCAL_DATA_ROOT` 指到可读目录、或撤掉本旋钮按旧形起动)。
  值取 on/off 双族词表(`on|true|1|yes|enabled` / `off|false|0|no|disabled`,空串=未设);**两族都不
  认得的词一律拒启**——静默回默认会把一次明确的 fail-closed 表态无声吃掉。只有 file 后端读它。
  ⚠️ 这条拒启**穿过**「裸 boot 的 local 存不了盘就退回内存」那条既有降级臂:开了这根旋钮就是要
  fail-closed,被降级成内存等于把它翻译成 fail-open(而且丢的正是持久 run 账本)。
  射程 = boot 期的**全部盘面**:`runs/`+`runs/active/` 的建目录、两次目录列举,加上逐个
  `run.json` / `events.jsonl` / claim 文件的读取。目录里本来就没有的东西(`.DS_Store` 一类杂物、
  尚未落盘的目录)照旧跳过 —— 拒的是「读不出来」,不是「没有」。

**监听绑址(1.306+)—— 桌面/单机形请注意**
- `BIND_HOST` = 监听地址;显式设置**恒生效**(要在无鉴权下对外暴露,显式写 `BIND_HOST=0.0.0.0` 即可)。
- `HOST` 不再被读取(曾经的兼容别名已撤——zsh 默认把 `HOST` 设成机器名的暗通道风险已消除):
  绑址唯一口 = `BIND_HOST`,设或不设都与 `HOST` 环境变量的值无关。
- **缺省自动收窄**:当写面无鉴权时(`ALLOW_UNAUTHED_WRITES=true` 且**完全没有**
  `SERVICE_AUTH_TOKEN`/TOKENS 名录)⇒ 自动绑 `127.0.0.1`,boot 日志点名原因。理由:该形常配
  `REMOTE_EXEC=host`(用户真机、非沙箱),绑全接口=同网段任何人可无鉴权提交任务并执行。
- 配了凭证的部署(云形/k8s/compose)缺省**不变**(全接口),既有部署零影响。
- **跨机不映射 cwd**:请求里的 `cwd` 是**服务进程所在那台机器**上的路径,且只有单用户 `REMOTE_EXEC=host`
  车道会尊重它(那条车道的定义就是"运维自己这台机、无容器边界")。所以「服务跑在 A 机、代码在 B 机」不成立
  —— 没有任何东西把 B 的路径映到 A。要么把服务(或 `run-local`)跑在放着文件的那台机器上,要么走容器车道
  (`e2b`/`k8s`/`local-docker`),那里的工作区是沙箱自己的,`cwd` 被忽略。
- **读面(S-426 起)**:`GET /v1/capabilities` 的 `executionLane` 把本部署的车道广告成
  `{provider:<REMOTE_EXEC 闭集词>, toolsOnThisHost:boolean}`(`REMOTE_EXEC` 未设 ⇒ `provider:"host"`)。
  壳/客户端判「工具跑不跑在本机」**读这一位**,不要再从自己这一侧的 `REMOTE_EXEC` 猜 —— 连远端引擎时
  那个 env 说的是**壳自己这台**的事。⚠️ 它是**文件系统同一性**位,**不是**权限位:`cwd` 兑不兑现仍由
  `projectContext` / `callerCwd` 回答(那两位的判据多一条单用户约束,多租户 host 部署上两者会分叉)。
  端点/凭据/镜像/`serial` 一个字都不上这条 wire,那些仍只在 operator 面 `GET /v1/config/catalog`。

**workspace 浏览面 —— 已随整树快照纪元退役**
- 旧四端点(list/tree/file/archive)恒 `501 capability.workspace_retired`,`capabilities.workspace`
  恒 `false`。理由:rewind 换代为 per-file 历史(只备份代理**编辑过**的文件,无树遍历、无体积门),
  结构上不再存在「整个工作区」的 manifest 可投影;把 tracked-set 呈成 workspace 是语义谎。
- `WORKSPACE_FILE_MAX_BYTES` 随面转 INERT(值仍收下并校验,center 推送兼容;不再有任何效果)。
- rewind 的新姿势:提交带 `resumeAt` + `restoreFiles: true`(或 code-only 的 `rewindFilesTo`)⇒
  引擎把**本会话跟踪集**收敛到该消息时刻的边界;`acceptPartialRestore: true` 容忍部分恢复
  (缺省=响亮失败带逐文件账本)。旧键 `rewindFiles` 的 capture 义=容忍 no-op(首触跟踪恒开),
  restore 义(与 resumeAt 同发)=typed 拒并指路 `restoreFiles`(迁移显式化是 core 裁定)。

**可选 — SendUserFile 文件直链(把沙箱/主机里的文件发给用户,S3/MinIO 双轨)**
```bash
# 开闸 = 对象存储三键(与 workspace/snapshot 面共用同名 env;三键任一缺席 → 工具不挂载,行为不变)
MINIO_ENDPOINT=http://minio.internal:9000   # 服务端上传走的内网 S3/MinIO 端点(凭据不出服务端)
MINIO_ACCESS_KEY=…  MINIO_SECRET_KEY=…      # 可选 MINIO_REGION;外接 S3 也可只给 S3_ENDPOINT(见下)
# 公网面(签发的链接用户浏览器要直连拉取;不设 → 工具挂载但签发时报错并提示此键)
S3_PUBLIC_ENDPOINT=https://files.example.com  # 用户可达的 S3/MinIO 公网 base(反代必须透传 Host——SigV4 绑定 Host)
S3_PUBLIC_BUCKET=sema-public                  # 永久轨匿名 GET 桶(默认 sema-public)
SESSION_SNAPSHOT_BUCKET=session-snapshots     # 限时轨私桶(与 snapshot 面共用;sendfile/ 前缀内隔离)
SEND_USER_FILE_URL_TTL=0                      # 缺省 ttl 秒:0=永久(默认);1..604800=限时签名链接;非法值回落 0
SEND_USER_FILE_SANDBOX_PUT_ENDPOINT=…         # 可选:沙箱直传 PUT 的端点(默认=S3_PUBLIC_ENDPOINT;
                                              # k8s 集群内 pod 可指内网 MinIO 省公网带宽)
```
- **词表优先级**:内网端点 `MINIO_ENDPOINT` 优先,`S3_ENDPOINT` 是外接 S3 兼容位(内网/公网同址场景可只给它,同时充当公网面);公网端点 `S3_PUBLIC_ENDPOINT` 优先,缺席回落 `S3_ENDPOINT`。空串一律按未设处理。
- **🔴 云形(`DB_BACKEND=mysql|pg`)快照 blob 强制对象存储(1.295+,clay 拍)**:缺 MinIO 三键 ⇒ **boot 拒启**——单行 SQL blob 写会撞 TiDB `txn-entry-size-limit`(默认 6MiB,真库实测矮墙)/ mysql 协议 `max_allowed_packet`,大字节归对象存储(与 D-1 附件面同裁定)。单机/测试台显式逃生:`SNAPSHOT_BLOB_ALLOW_SQL_BYTES=true`(SQL 店此时带 per-blob 帽,tidb 默认 6MiB,超限 PUT 413 `blob_too_large_for_sql`;帽可用 `SNAPSHOT_BLOB_SQL_MAX_BYTES` 按部署真实限值覆写,两方言生效;pg 默认无帽)。
- **per-file rewind 历史的边界上限**(与 workspace 浏览面同一个整树快照纪元退役批留下的保留条款):`FILE_HISTORY_RETENTION_KEEP` —— 每个 scope(会话)存活的**最新** boundary 数,**每次 boundary 提交后自剪**(最旧者先亡,刚提交者恒存活;失引用的版本行级联删,blob 字节走异步 grace 窗 GC;v1 基线永存)。未设=core 缺省 **100**(由 core 的 `resolveFileHistoryRetention` 兑现,server 不复制那个数);`unbounded` 是**唯一**的「不剪」拼法(整店自剪关闭,GC 归部署自排 `reap`);`0`/负数/小数/非数 ⇒ **boot 拒启**(码 `config.retention_policy_invalid`,与 core 内置后端同码同判——**省略这个键不等于不剪**,它选的是缺省 100)。三条车道(TiDB / PG / local 文件店)同旋钮同判。⚠️ 升级即生效:存量超 100 boundary 的 scope 在下一次提交时被剪到 100;要保全量先写 `unbounded`。被剪 entry 的键**已花掉**(再次 snapshot 同 entry 典型拒,不复铸)。排序=DB 分配的 per-scope 发布序号 `file_history_boundary.publish_seq`(不是墙钟):**自 7.50.x 有存量 `file_history_boundary` 的部署升级本版会在 boot 期被 `assertFileHistorySchema` 拒启并指明 `DROP TABLE file_history_boundary`**(本仓无 ALTER;丢的是 per-file rewind 便利态,会话与其他持久面不动)。
- **🔴 公桶策略必须 GetObject-only**:`mc anonymous set download` 会**连带打开 ListBucket**——匿名 `GET /<bucket>/?list-type=2` 能枚举全部不可猜 key,能力链接设计即告失效。正确姿势=`mc anonymous set-json`,policy 只含 `Action:["s3:GetObject"]` on `arn:aws:s3:::<bucket>/*`(验证:对象 GET 200、桶 LIST 403)。
- **两条轨**:ttl=0(默认)→ 匿名 GET 公桶下 `uuidv7/<name>` 不可猜 key,链接永久、可回收(删对象);0<ttl≤7 天 → 私桶 + SigV4 限时签名链接(7 天是 SigV4 物理上限,更久用 ttl=0)。
- **执行 lane 与租户门**:e2b/k8s 沙箱 lane 任意租户可用(沙箱文件系统=租户边界,沙箱内 `curl -T` 直传、字节不中转、凭据不进沙箱);host/ssh lane 仅单用户部署(`REQUIRE_PRINCIPAL` 未开)时挂载。
- **web 侧感知**:`GET /v1/capabilities` 透出 `sendUserFile`(READY 语义:工具真挂载**且**公网端点在场——调用真能成功才 yes)与 `s3PublicEndpoint`(渲染用公网 base;绝不透出密钥/内网端点)。

**可选 — 接配置控制面(中心化模型/角色/团队配置)**
```bash
# 新名(orchestrator 现注入);旧名 CONFIG_CENTER_* 在场即 boot 拒启,只认 SEMA_REGISTRY_*
SEMA_REGISTRY_URL=http://<config-center-host>:3100   # 启动拉 GET /api/config/effective(Bearer+ETag),覆盖 env 兜底
SEMA_REGISTRY_TOKEN=<SERVICE_PULL_TOKEN 的值>   # 取自配置控制面主机 .env;只读拉取令牌
SEMA_REGISTRY_DRY_RUN=true                     # 安全灰度:只 LOG 中心配置 vs env 推导的差异,不 apply
SEMA_REGISTRY_WORKER=<worker名>                # 可选:拉取 /effective?worker=<名> 取该 worker 的 roster(reconciler 按 worker 注);不设=全局 roster(向后兼容)
FLEET_ADVERTISE_ADDRESS=http://<本机可达IP>:8090  # 可选:设了才启 fleet 上报腿(announce/heartbeat+usage 批报到中心)。
                                               # 必须是可解析的 http(s) base URL——host:port 等坏形 5.13.0 起 boot 响亮拒
                                               # (此前静默每拍 announce 400,worker 永不注册)。
```
- 中心**空/未发布** → `applyEffective` 回落 env + 内建 teams 并 warn `config_center_unpublished`,**不影响在跑的服务**(接了也安全)。
- **灰度姿势**(配置控制面 AI 建议):先 `SEMA_REGISTRY_DRY_RUN=true` 起一轮,看日志 `sema_registry_dry_run`(中心给的 models/roles/teams + 会否覆盖 default、per-model apiKeyEnv)对得上 env 再去掉该 flag 真正 apply。
- 拉取**只读、只取逻辑配置**(模型名册/角色/团队);密钥/网关仍在本服务 env(中心只发 env-**名** 引用,不发密钥值)。回滚=去掉 `SEMA_REGISTRY_URL` 即纯 env。
- **配置热更新真表(7.38+ 现行)**——refresh 拍(60s 轮询,或 `POST /v1/admin/config/refresh` 手动触发,见 API 表)对各域的生效方式:

  | 域 | 生效 | 说明 |
  |---|---|---|
  | models / roles / default 模型 | **热**(下一 refresh 拍) | 全 boot Runner **原子换代**(swap 失败=候选整拒,活配置零触碰);此前「改 models 需重启」的时代已随 Runner swap 腿落地终结 |
  | pricing / keys(env-名引用)/ prompts / teams | **热** | 值换代即生效;cost 族限额座(rate limit / cost quota)同热(限额=纯比较参数,窗内累计不动;窗长换代=记账周期重开) |
  | **tier 变更**(models-tiers plane 与 tier-frozen 基线不一致) | **defer 到重启**(唯一 defer 臂) | 候选 durable 落地(LKG,`CONFIG_LKG_DURABLE`)后 `/health` 报 `restartRequired`;无 durable handoff ⇒ `/health` 报 `modelPlaneDeferred{version,since,blockedReasons}` 候运维(不强制重启,plane 保持未应用) |

  生效与否的观测口=`/health` 世代账键(`configTargetVersion`/`configAppliedVersion`+两 ordinal+`configApplyStaleMs`,见 API 表 `/health` 行)——target≠applied 持续=「改了没生效」的机读信号。
- **仅 `/v1/tasks`(同步)+ `/v1/runs`(异步)**——`/v1/tasks/stream` 不支持(多次尝试非单流,400)。与 `verify` **互斥**(同时给 → 400)。
- 成本上界 = 任务的 `maxCostUsd`(防冷重跑税)。**三条 core 警示**:① 每档**冷重跑**重付输入成本(便宜档常过才划算);② 门收到**未脱敏**输出(自定义门转发外部 verifier 要脱敏);③ **写工具会跑 N 次**——**只用于只读/幂等任务**(每次升级整任务重跑)。开放式任务(找全 bug/文笔)没有可判定 oracle、级联会空转,那种用 `discuss`(广度交叉)而非级联(深度阶梯)。

**可选 — AskUserQuestion 的裁量窗(问活人的三个帽子)**
```bash
# 每条 run 腿同时能挂几个问题(并发帽,1..64)
QUESTION_MAX_CONCURRENT_PER_RUN=2
# 每条 run 腿一共能问人几次(总量帽=防刷屏,1..10000)
QUESTION_MAX_TOTAL_PER_RUN=20
# 一个没人答的问题挂多久后释放(毫秒,1000..3600000;默认 5 分钟)
QUESTION_TTL_MS=300000
```
- 三个值都有出厂缺省(即上面写的值),**不设=行为不变**;越界值**启动期响亮拒**(不静默夹取)。
- 窗用尽 / 到期 **不是替人作答**:服务只报「此刻无人可答」,落点由引擎选——**durable 部署**(`DURABLE_APPROVAL=true` 且这条腿没有活的流)把问题 **park** 成待办,运维随后经审批面带答案补答;**无 durable 的部署**则让模型被告知"没人在"后按自己的判断继续(run 永不挂死)。
- 想让人有更长时间答就调大 `QUESTION_TTL_MS`;想让 agent 少打扰人就调小 `QUESTION_MAX_TOTAL_PER_RUN`。
- 🔴 **`/v1/tasks/stream` 的开帧投递失败是响亮的(逐腿,不是部署级)**:这条腿(**且仅这条腿**)向引擎
  声明 `interactionPosture: "interactive"` —— 人就在这条 SSE 上,一次开帧投递失败(壳断连/流被撕裂)
  不再合成「没有人可问,你自己判」的续跑卡,而是让那次工具调用返回 coded 失败
  `question.human_unavailable`。**其余每一条腿行为字节不变**:`/v1/tasks`(非流)、`/v1/runs`、A2A、
  resume、cron / wake、`verify` / `cascade` 一律**不声明** ⇒ 续跑 + 披露卡 + 既有计数照旧
  (那些腿本来就没人可问,拒掉它们只会把无人值守任务打死)。
  声明本身有前提:活体问答面与活体审批席**两个席位都在**、且调用方没有把 `interactiveTools` 声明成
  `false` —— 缺任何一项就不声明(引擎的 posture 门是**拒整条腿**的,声明一个够不着人的姿态会让每个
  任务在准备阶段当场失败)。**发了 `x-detach-on-disconnect: true` 的请求同样不声明** —— 那个头逐字是
  「我可能随时走开,别杀 run」,正是 interactive 的反面。
  ⚠️ **射程如实**:引擎的姿态解析是三腿的,**从这条腿委派出去的子代会继承根腿的姿态**(引擎的设计,
  宿主覆盖不了;语义自洽——子代的提问浮到的正是同一条流上的同一个人)。所以「无人腿不受影响」说的是
  以无人腿**提交**的 run,不是「任何后台工作」。
  要在活流腿上保留旧的自答续跑行为,目前的办法是把该请求改走 `/v1/tasks`(非流)或 `/v1/runs`,
  或带上 `x-detach-on-disconnect: true`。

**流内审批协议(`approval_request` 帧族)—— 总开关 + 九个调优钮**

> 🔴 **默认已是 ON**(clay 裁 2026-08-08;7.3.0/7.4.0 的**已发布**线上仍是 OFF,翻转落在其后的下一个发布)。
> 开关只是发帧五合取里的一项:还要**活卡腿开着 ∧ 非 local 的持久 store backend ∧ park 设施在场**
> (`DURABLE_APPROVAL=true` + checkpoint 能力的 backend)。所以一台什么都没配的默认 worker 仍是零帧 +
> 启动一行 `stream_approval_disabled`;而**已经配齐那套前置的部署,升级后不必再显式开这个开关就会开始发帧**
> —— 壳/SDK 不实现消费就等于用户看不到审批卡。帧格式、消费端契约、五合取全表在
> [`docs/ASSISTANT-WIRE-CONTRACT.md` §4a-quint](docs/ASSISTANT-WIRE-CONTRACT.md)(错误码 `feature.approval_ask_disabled` 在附录 A)。

```bash
# 协议总开关。默认 true;显式 =false 是**本协议面**的干净还原键(不发 approval_request、不落 ask 行、
# 不起收敛器腿)。⚠️ 它**不**回滚 7.34.0 的 R-13 窗到期语义——那一条的
# 回滚键是下面的 UNATTENDED_APPROVAL_POLICY=deny(两根旋钮正交,别当一根用);它也**不**再回滚窗长
# (窗是活卡件不是协议件,见下一键——缺省值与旧硬编码窗同为 300000,没配过就仍然字节不变)
STREAM_APPROVAL_ENABLED=true
# 活卡窗(毫秒)。**辖域=所有腿**:协议上不上场都按它计窗——无 checkpoint 店 / 关了协议的部署同样
# 生效(此前那几形上本键一个字节都不生效、窗恒 5min,且没有任何一行说出来)。
# 🔴 **缺省值是 posture 派生的,不是一个数**(S-178):单机 turnkey(`REQUIRE_PRINCIPAL` 未设)=
# **86400000(24 小时)**,多租户形 = 300000(5min)。**S-384 起这根旋钮在单机上是要读的那一根**:
# local 后端自此有持久 ask 账、流内协议上场,受门 ask 先发一张活卡、**窗满才转 park**(升级前是铸造
# 时刻立刻 park)⇒ 不配它的**无人值守**单机最多挂一天才落 park。无人值守机器请显式给几秒;
# 有人值守的单机正是那一天窗口的受益者。0 = 运维显式关窗 ⇒ **一张卡都不发**、恒走无人值守终局(不是"还原",还原用上面那个键;
# 缺省 park 政策 + park 设施在场 ⇒ 全落 durable park 经 /v1/approvals/:sessionId/decide 兑现;
# 配了 UNATTENDED_APPROVAL_POLICY=deny 或没有 park 设施的部署这里是"恒当场拒"= 受门工具全关,
# boot 期打一行 stream_ask_window_zero 点名)。负值**拒启**(此前静默等价于 0 还白发一张没人赢得了的卡)
STREAM_ASK_WINDOW_MS=300000
# 协调器窗长三元的安全余量(毫秒,默认 10000):有效窗 = min(ttl, 本 leg 余量 − 本值)
STREAM_ASK_WINDOW_MARGIN_MS=10000
# 开流重放:一次最多投几张未决卡(默认 50)。超出只投最新的并记一次 warn,开流不失败
STREAM_APPROVAL_REPLAY_MAX=50
# 写侧准入帽:单个 task / 单个 owner 的未决 ask 上限(默认 32 / 256)。超限走 park(**缺省 park 政策下
# 永不 deny**;配了 UNATTENDED_APPROVAL_POLICY=deny 的部署按它的自声明当场拒——见下面那段)
STREAM_APPROVAL_ADMIT_MAX_PER_TASK=32
STREAM_APPROVAL_ADMIT_MAX_PER_OWNER=256
# 对账收敛器每 tick 每段最多处理的行数(默认 200,有界 [1,10000],且必须是**整数**)
STREAM_APPROVAL_RECONCILE_BATCH=200
# 崩溃恢复扫描的宽限(毫秒,默认 30000,有界 [0,3600000]):只有 expiresAtMs+本值 已过的孤儿行才被代打过期
STREAM_APPROVAL_PENDING_GRACE_MS=30000
# adhoc 腿的窗后宽限(毫秒,默认 60000,有界 [0,86400000])
STREAM_APPROVAL_ADHOC_GRACE_MS=60000
# 遗孤最终可判上界(毫秒,默认 604800000=7d,有界 [60000, 7776000000=90d]),量的是 createdAtMs
# 单机交互形推荐调低到 172800000(48h):一张卡挂两天没人批在单机上基本等于死卡,7d 默认是按
# 多副本云形定的;调低让 resume 对账少扫陈年行(clay 裁=文档推荐不改全局默认)
STREAM_APPROVAL_ORPHAN_TTL_MS=604800000
# 案A(7.52.0):**零活流铸造**的 ask 的悬挂窗(毫秒,默认 3600000=1h,有界 [60000, 86400000=24h])。
# 只作用于「到达时该 (owner, session) 下一条活 SSE 都没有」的那一类 —— 典型是 workflow 子代 ask
# (workflow 恒后台跑,人不在场);bg 提交腿(POST /v1/runs)与 resume 腿的委派子代 ask 现同席
# (此前那两条腿结构性无席位,恒即拒,连本窗都够不着)。此前那一刻即答「无人可答」⇒ 整条命令死拒;现在它照常落一条
# STREAM_PENDING 行等人,人经 GET /v1/approvals / 壳队列 / 重连开流补投三条通道回来答,答完子代原地续跑;
# 窗满才走既有 park/deny 出路。**有活流的 ask 一毫秒不受影响**(仍是 STREAM_ASK_WINDOW_MS)。
# 调小 = 更接近旧行为(积压更少、但人来晚了就赶不上);UNATTENDED_APPROVAL_POLICY=deny 的部署完全不受
# 本键影响(那台机器上零活流的 ask 仍是当场拒,不挂窗、不占配额)。
UNREACHED_ASK_TTL_MS=3600000
```
- **坏形一律启动期炸,不静默折默认**:五个 `numEnv` 键(`STREAM_ASK_WINDOW_MS` / `STREAM_ASK_WINDOW_MARGIN_MS` /
  `STREAM_APPROVAL_REPLAY_MAX` / 两个 `ADMIT_MAX_*`)非数字即拒启;`STREAM_ASK_WINDOW_MS` 另加一道
  **非负门 + 上界门**(负值此前静默落成「立即到期」并白发一张没人赢得了的卡;超过 Node 定时器
  上界 `2147483647`(2^31−1 ms ≈ 24.8 天)的值被 `setTimeout` 静默折成 1ms —— 想调长反而立即到期,而跨旋钮
  门只管 `窗 + 宽限 < orphanTtl`、orphanTtl 域上界是 90 天,所以 30 天的窗此前合法通过。两侧一律点名拒启,
  不夹取;`0` 仍是合法的「显式关窗」姿态);四个收敛器键 + `UNREACHED_ASK_TTL_MS`
  额外**有界**,越界拒启(不夹取);`STREAM_APPROVAL_RECONCILE_BATCH` 再加一道整数门(`200.5` 会一路走到
  SQL `LIMIT` 上)。
- **🔴 跨旋钮不变量**:`STREAM_ASK_WINDOW_MS + STREAM_APPROVAL_ADHOC_GRACE_MS` 必须**严格小于**
  `STREAM_APPROVAL_ORPHAN_TTL_MS`,否则**拒启**并点名。理由是归因诚实:adhoc 判据在
  `(创建 + 窗 + 宽限)` 触发、遗孤兜底在 `(创建 + TTL)` 触发,兜底若先到,每条 adhoc 腿都会被记成
  `orphan_ttl_exceeded`(「遗孤」)而不是 `adhoc_leg_no_durable_domain`(「结构上无对账域」),审计面从此读不出真成因。
  **同一条不变量对悬挂窗再判一次**(7.52.0 案A):`UNREACHED_ASK_TTL_MS + STREAM_APPROVAL_ADHOC_GRACE_MS`
  同样必须严格小于 `STREAM_APPROVAL_ORPHAN_TTL_MS`(悬挂行的 `expiresAtMs ≈ 创建 + 悬挂窗)。两条分开判
  是为了让拒启文案点得出**是哪一个键**破了不等式;默认值(1h + 60s vs 7d)自然满足。
  ⚠️ 悬挂窗这一条**不是无条件**:门挂在「这台部署会不会铸出悬挂行」上,三形豁免(配置期可知)——
  `STREAM_APPROVAL_ENABLED=false`(协议不上场,零 ask 行)、`TOOL_APPROVAL_ENABLED=false`(协调器不构造,
  core 走 headless 缺省,零 ask 行)、`UNATTENDED_APPROVAL_POLICY=deny`(零活流的 ask 铸造时刻即拒,
  悬挂路径结构上不可达)。三形下该键即使与短 orphan TTL 组合也照常起服 —— 一批从未选择本能力的既有
  部署不因升级引入的 1h 缺省当场拒启。上一条(`STREAM_ASK_WINDOW_MS` 版)保持**无条件**,与既有行为逐字一致。
- **断连时活卡怎么办**:客户端**优雅断开**(server 收到 socket close)时,流内未决 ask
  立即强转 durable park(7.16.0 起),恢复后 attach 重放会重呈卡。客户端**网络中断**(TCP half-open,
  server 侧 close 事件不触发)是已知盲窗:SSE heartbeat 帧只会堆进 TCP 重传队列,server 要等 OS 级重传
  耗尽(十几分钟量级)才感知——这段时间内真正的兜底是**活卡窗到期转 park**(`STREAM_ASK_WINDOW_MS`,
  默认 5min),所以把窗改大等于把断连场景的最坏无人区拉长,改小前先看上面的跨旋钮不变量。half-open 的
  主动探测(写水位判死)在攻关排期。
- **无 durable 前置的部署(四合取不满足:无 backend / 无 checkpoint 能力 / 显式关协议 / 无活卡腿;
  ⚠️ **S-384 起 `local` backend 不再是其中一项** —— 它有了文件形 ask 账,配齐 `DURABLE_APPROVAL=true`
  + checkpoint 店的单机部署上协议**照常上场**,`GET /v1/capabilities` 的 `streamApproval` 报 true)**:流内协议整个不上场
  (启动一行 `stream_approval_disabled` 点名缺哪项),ask 走旧活卡腿——断连场景的最坏结局是活卡 TTL
  窗满**自动 deny(fail-closed)后 run 继续**,不会死锁在等一张没人能回的卡上;代价是断连期间用户的
  批准机会直接过期。(7.34.0 起这条 deny 由**引擎**在「没有可停靠的 durable 门」时给出——服务侧只报
  「此刻无人可答」,见上面的 `UNATTENDED_APPROVAL_POLICY`;结局不变,拒绝理由的文案更准确。)要「断连也不丢决策」的语义,配齐 durable 前置(持久 store + `DURABLE_APPROVAL=true`)。
  ⚠️ **这条腿上的窗照样是 `STREAM_ASK_WINDOW_MS`**:此前它被硬编码成 5min、旋钮在这些部署上
  静默无效(慢组场景只能真等满 5 分钟);现在调它就是调这条腿。另:这类部署上**迟到决议**
  (卡挂过窗、人才按 Yes)不能走 `POST /v1/tool-approvals/:id/respond`(没有持久 ask 行 ⇒ 恒 404
  `tool_approval.not_pending`、不带 `cause`),要走 `POST /v1/approvals/:sessionId/decide`
  ——前提是配了 `DURABLE_APPROVAL=true`(local 车道有文件形 checkpoint 店),否则窗到期即 fail-closed 拒。
  ⚠️ **上面这段「没有持久 ask 行 ⇒ 恒 404」自 S-384 起不再适用于 `DB_BACKEND=local` 本身**:local 的
  ask 账已是文件形,配齐 park 设施的单机部署上迟到决议走 `respond` 的赎回席是通的;本段现在只说
  那几条**真的**不上场的部署形(无 backend / 无 checkpoint 能力 / 显式关协议)。
  默认值(300s + 60s vs 7d)自然满足,只有显式改坏才会撞上。
- **调参方向**:想让人有更长时间点审批卡 → 调大 `STREAM_ASK_WINDOW_MS`;卡太多刷屏 → 调小两个 `ADMIT_MAX_*`
  (代价是超限的 ask 走 park,要有人去审批队列捞);库压大 → 调小 `STREAM_APPROVAL_RECONCILE_BATCH`
  (代价是收敛变慢,靠队列轮转保证下轮接着扫)。

**无人值守政策(`UNATTENDED_APPROVAL_POLICY`,7.34.0 起)**

```bash
# 闭集 park|deny,缺省 park。拼错词**启动期响亮拒**(不静默回缺省)
UNATTENDED_APPROVAL_POLICY=park
```
- 管的是**同一个问题**:一只需要人批的 ask 到了,而此刻**没有任何人**能答(窗走完没人点、连接全断、
  运维把窗关成 0、bg/headless 腿本来就没有活流、未决卡撞上准入帽)。
  - `park`(缺省)⇒ 服务报「此刻无人可答」,**durable 部署**(`DURABLE_APPROVAL=true` + checkpoint 能力的
    backend)把 run **挂起候人**(`done(suspended)` + `pendingGate`),人回来经审批面补批就接着跑;
    没有 park 设施的部署由引擎 fail-closed 拒绝并告诉模型「没有可停靠的 durable 审批门」。
  - `deny` ⇒ **真无人值守/headless 部署的显式声明**:这类 ask 当场拒绝,让模型自己改道,不积压一堆
    等不到人的挂起 run。它是**收紧**方向(不放行任何东西)。
- **7.34.0 的行为变化(park 侧)**:此前「窗走完没人答」是**当场拒绝 + 一条 error 回模型**(模型往往就
  绕开了那次治理);现在它与其余「无人可答」的情形一样走 park。要恢复旧结局请显式配 `deny`。
- 纯部署级:**不看**客户端的任何表态/权限模式。协调器整体关掉(`TOOL_APPROVAL_ENABLED` 关)时这个旋钮
  没有施加对象;`APPROVAL_REQUIRE` 名单里的工具走的是 durable 审批门,不受它影响。
- 🔴 **与 `STREAM_APPROVAL_ENABLED` 正交**:把流内协议开关关掉**不会**回滚 R-13 —— 它只是不发
  `approval_request`、不落 ask 行、不起收敛器腿,活卡窗到期照样走 park(park 的承载是引擎的 checkpoint,
  不是 server 的 ask 行,「有 durable 设施但没开协议」正是 R-13 要救的那类部署)。要回到旧的「窗满即拒」
  就配 `UNATTENDED_APPROVAL_POLICY=deny`。

**可选 — 写保护名表(S-138;缺席=引擎缺省表在岗)**

```bash
WRITE_PROTECTED_EXTRA=.corp-deploy-key,team/secrets      # 加法:[...引擎缺省表, 这两行]
WRITE_PROTECTED_TABLE_REPLACE='[{"name":".envrc","kind":"basename"}]'   # 整表替换(与上一根互斥)
```
- **这是什么**:core 的**字面名表**写保护 —— 一次落在表行上的可定路径写(Write/Edit/NotebookEdit)会把
  幸存的 `allow` 降级成 `ask`(拒/问的裁决不受影响)。表的缺省内容是 CC 的
  `DANGEROUS_FILES`/`DANGEROUS_DIRECTORIES`/`DANGEROUS_DIRECTORY_PATHS` 三形 + 两行 sema 自有行。
  匹配是**字面**的(`kind`:`basename` = 末段同名 / `segment` = 任意路径段同名 / `segment-run` = 连续段序列),
  裸名简写 = `segment`(更宽的那一档),含 `/` 的裸名 = `segment-run`。
- **两根旋钮按意图分家**:core 的座是**整表替换**,所以「我想再保护两个文件」只能走 `WRITE_PROTECTED_EXTRA`
  (它组合成 `[...缺省表, …]`,结构上丢不掉缺省行);真要换整张表才用 `WRITE_PROTECTED_TABLE_REPLACE`
  (只收 JSON;`[]` = 显式「完全不要这张表」)。**两根同写 = 拒启**(一条语义面不许两个写者)。
- **两形怎么判**:值里出现 JSON 结构字符(`[` `]` `{` `}` `"`)⇒ 当 JSON 判 —— 解析失败或顶层不是**数组**
  一律拒启(`WRITE_PROTECTED_EXTRA='{"name":".x","kind":"basename"}'` 这种少写一对方括号的写法会被当场拒,
  而不是被拆成两个垃圾裸名静默收下);否则走逗号裸名表。真有名字带引号/花括号的文件 ⇒ 走 JSON 形。
- **响亮**:整表替换在 boot 期发一条日志**逐名**列出被丢的缺省行(`write_protection_table_replaced`),
  `[]` 发 `write_protection_table_disabled`。空值 / 坏值(通配符、未知 kind、kind 与名字段数矛盾)一律
  **启动期拒**,文案带引擎原话 + 出问题的 env 名。
- **缺席 ≠ 没装**:不设这两根 ⇒ 座不铸 ⇒ **引擎的缺省表在岗**(server 不复制那张表)。要确认这台机器上
  它到底在不在,读**读面**而不是猜:
  - 租户面 `GET /v1/capabilities` → `writeProtection: {armed, rows, replaced}`(只给计数,不给行名);
  - operator 面 `GET /v1/diagnostics/wiring` → `writeProtection: {rows[], source, droppedDefaultRows?}`
    (`source`:`default` / `extra` / `replace` / `off`;逐行内容只在这一面)。
- **与 `SENSITIVE_WRITE_PATTERNS` 并列、不合流**:那一根是**模式**(正则/glob)型 DENY 集,这一根是
  字面名表的 `allow → ask` 降级。两套互不覆盖,读面也分开报 —— 一条路径被哪一套拦住,是两个不同的问题
  (解法一个是改模式、一个是改名表)。
- **leader 车道同席**:与其余部署姿态席同一个取值点(`buildDeploymentPostureSeats`),主 runner / subRunner /
  run-local / leader 五只 Runner 同表。

**可选 — READ 面姿态(readFace,7.18.0 起;缺席:单机 ⇒ `open`,多租户 ⇒ 引擎当家 —— 7.65.0 起,见下)**

```bash
READ_FACE=open                       # 词表 open|roots;坏词启动期响亮拒
                                     # 不设:单用户 turnkey(REQUIRE_PRINCIPAL 未设)⇒ open;多租户 ⇒ 不传给引擎(引擎默认 roots)
READ_DENY_PATTERNS='[{"pattern":"**/secrets/**"},{"pattern":".ssh","caseSensitive":true}]'
```
- **`READ_FACE`**:READ 工具面(读文件/遍历/搜索)的部署级姿态。引擎默认 **`roots`** = 读被夹在任务的
  workspace roots 里;`open` = 部署显式放开(单用户自托管、要读全盘配置那种形)。任务层只能**收紧**
  (spec 的 `readFace:"roots"`),放开只有这一个部署座席——层级方向与写门一致(部署默认压不过承重墙)。
- **`READ_DENY_PATTERNS`**:JSON 数组,元素 `"pattern"` 或 `{pattern, caseSensitive?}`。语义是
  **路径段窗**匹配:pattern 按 `/` 分段,`*` 是唯一元字符(段内通配,不跨段),整段序列可命中路径**任意
  位置**(`.ssh` 同时盖住 `~/.ssh/…` 和别处的 `.ssh`);默认 ASCII 大小写**不敏感**,单条 `caseSensitive:true`
  退出。这些条目**叠在** core 内建 deny 集之上(内建先、追加后,重复折叠),不是替换。
- **坏形拒启,不静默折默认**:坏 JSON / 非数组 / 缺 `pattern` / 编不过 core 的 `compileReadDeny`
  一律启动期点名拒——一个手滑的 deny 集比没有 deny 集更糟(以为挡了其实没挡)。
- **缺席的语义按 posture 分两形(7.65.0 起,S-167)**:
  · **单用户 turnkey**(`REQUIRE_PRINCIPAL` 未设 —— 一台机器一个可信主人,CC 的信任模型):`READ_FACE`
    缺席 ⇒ 铸 **`open`**,工作区外的读默认放行。**这是 7.65.0 的行为变更**:同一台机器升级前后
    「读工作区外的文件」从被拒变成放行。**要钉回收敛姿态:显式 `READ_FACE=roots`。**
  · **多租户**(`REQUIRE_PRINCIPAL=true`):缺席仍**不铸**、不传给引擎(引擎默认 `roots`)——**一个字节不变**。
  · `READ_DENY_PATTERNS` / `READ_DENY_BUILTIN_TIERS` / `READ_DENY_BUILTIN_EXCLUDE` 三键的「缺席不铸」
    **不变**。⚠️ **但「不铸」在引擎里的读法自 core 7.22.0(内置读 deny 表出厂 OFF 的裁定)起翻了面**:`READ_DENY_BUILTIN_TIERS`
    未设 = **OFF**(内建表一行不生效),此前是「除 shell-history 外四档全开」。⇒ 一台没配过这根 env 的机器
    升级后**读得到**根内的 `.npmrc` / `.ssh` / `.aws` / `.claude/settings.json` 这类名字,且整个递归读族
    零卡执行。**要老姿态就显式写** `READ_DENY_BUILTIN_TIERS=credentials,browser,wallet,agent-config`
    (运维面全文见 `docs/DEPLOY-PREREQS.md` 的 core 7.22.0 段)。`open` 与这张表仍是**两个并列机制**:
    `open` 放开的是「工作区外」那道围栏,内建表判的是名字 —— 只是后者现在默认空着。
  · 🔴 **命中这三张表的结局自 core 7.25.0 起是「拒」,不是「问」**(三只旋钮的名字 / 语义 / 缺席读法一字
    未变,变的是结局词):一条命中拒表的读在**每张脸每个时刻**都被拒 —— 不出审批卡、持久 allow 规则
    清不掉、值守的人点「同意」也放不掉。拒因在 `tool_end` 上:`errorCode` 与 `structured` 都是
    `read_path_denied`(带 `target` 与**命中的那条 pattern**),`gate.disposition.deniedBy = "read_boundary"`。
    同批**根内的内容遍历**(`grep -r` / `diff -r` / `rg`)改成**枚举**:树下没有拒表行 ⇒ 零卡执行,
    有 ⇒ 点名那一行拒。⇒ **要让某条路径重新读得到,就把它从这三张表里拿掉**(删掉那条自配模式 / 档位
    不选 / 用 `READ_DENY_BUILTIN_EXCLUDE` 删掉那一行);三问与运维面全文见
    `docs/DEPLOY-PREREQS.md` 的 `core 7.25.0 提货` 段。
  · 「这一档到底是谁定的」由 operator 面 `GET /v1/diagnostics/wiring` 的 `readFace` 段答:
    `{face, source, note}`,`source` ∈ `env`(本机 env 显式)/ `center`(组织下发)/ `posture`(单机派生)/
    `engine-default`(没钉,引擎当家),`note` 是指路句。
- **显式值恒赢**:优先序 `env` > `center` > `posture` > 引擎默认;引擎抬默认/扩内建集那天,没钉过的
  多租户部署自动跟上(与重试上限同款「引擎当家」纪律)。
- **leader 车道同席(7.54 起)**:`LEADER_ENABLED` 的 leader 编排(planner / worker / repair / conflict 四类
  任务)与主车道吃**同一份**四键(连同 `MEMORY_DELEGATION_EVIDENCE` / `MEMORY_PROVENANCE` / `MEMORY_CAPTURE_POLICY`
  与委派入口 caps;取值点唯一 = `buildDeploymentPostureSeats`,leader 不另读 env)。此前(≤7.53)那条腿一席都不带:
  deny 追加对 leader worker **静默不生效**(core 内建 deny 表仍在)。扩面(7.56 起)再补三席:计费/遥测
  `tracer`(连同后台腿的 principal 归因)、治理窗 `USAGE_WINDOWS` 两键(连同 `POST /v1/leader` 的准入门)、
  center 发布的提示词目录 —— 治理窗那一席的细节见 §9.2。
- **组织下发腿(config-center,7.29 起)**:四键(`READ_FACE` / `READ_DENY_PATTERNS` /
  `READ_DENY_BUILTIN_TIERS` / `READ_DENY_BUILTIN_EXCLUDE`)也可由 config-center 的 `readFace` 域下发
  (`{face?, denyPatterns?, denyBuiltinTiers?, denyBuiltinExclude?}`)。三条规则:
  ① **本机 env 显式恒赢**(逐键判)——env 是这台机器的部署主权,下发值不会静默盖掉它,被忽略的键留一条
  `sema_registry_read_face_env_wins` warn;② **坏值整域拒**——任何一个成员过不了引擎自己的校验器就整域不落、
  保留 env 前值 + `sema_registry_read_face_invalid` warn(半应用一份 READ 姿态比不应用更危险);
  ③ **一席热、三席重启**(server 7.69.0 起,引擎侧交付了 read-face 的活体座席):`face` 是**热换席** ——
  下发或撤回一个档位,下一拍 refresh(≤60s,或手动 `POST /v1/admin/config/refresh`)当场换进全部 Runner,
  **不重启、也不再签 `read-face` 重启理由**(已在飞的任务保留它准备时的档位,新任务读新档);三个 deny 键
  (`denyPatterns`/`denyBuiltinTiers`/`denyBuiltinExclude`)**仍是 boot-baked**(core 的可换席闭集只收了
  `readFace` 一席),下发改动仍经 `/health` 的 `restart.reasons` 带 `read-face` 通告、由编排器重启兑现。
  当前生效档可从 `GET /v1/capabilities` 的 `readFace` 位读
  (`"open"`/`"roots"`/`null`=本部署未钉,引擎默认当家;deny 表内容属策略内容,**不上**能力面);
  **来源**(env / center / posture / engine-default)在 operator 面 `GET /v1/diagnostics/wiring` 的 `readFace.source`。
  ⚠️ 本地 `config.d/` 车道今天**接不上**这一域(registry-core 的可移植域表里没有它),只有
  `CONFIG_PROVIDER=remote` 的部署走得通。

- **MCP 撤销腿(7.39 起)**:MCP 服务器 mount 仍是 restart-to-apply(新增/改址要
  restart 才生效),但**从 enabled 集删除一个服务器即时生效**——refresh 拍收货后,被删服务器的
  MCP 调用当场按 coded 拒绝 `mcp.server_revoked` 结算(known-not-executed,引擎每次派发前探询
  台账,不缓存)。因此 `/health` 的 `restart.reasons` 对**纯删除向**不再报 `mcp`(签了就是为一个
  已生效的变更滚动重启 fleet);删除+新增/改址混合拍照签。
  **恢复=指纹判,不是名字判**:把服务器加回 enabled 集,只有当**整条目与 boot 那一份逐字同形**
  (全字段指纹,`enabled` 键也算一位)时才解除撤销。三形**保持 revoked**、只能候 restart 收敛:
  ①改址/改配置后同名加回(复活的会是 boot 冻结的**旧** mount,新形要 restart 才装上 ⇒ fail-closed);
  ②boot 条目本没写 `enabled` 键、却用 `enabled:false → true` 开关切回(带上这个键就已经不同形了;
  想走开关撤销/恢复,boot 那份条目里就要显式写 `enabled: true`);③boot 期压根没铸出 baseline
  (中心迟到到货 / boot 超时 ⇒ mount 集=env 集),那台机器整条中心撤销语义不适用,恒不解除。
  ①②两形 `/health` 的 `restart.reasons` 会签 `mcp`,按信号滚动重启即收敛;判「解除了没有」看下面那个
  被撤名单,别按「我加回去了」推断。
  当前被撤名单可从 `GET /v1/diagnostics/wiring` 的 `mcpRevocations.revoked` 读(`null`=本进程无
  配置管道);`GET /v1/sessions/:id/mcp` 面板与执行面同一台账(被撤服务器的工具不再展示)。
  纯 env 配置的 `MCP_SERVERS` 部署无中心撤销语义,行为逐字不变。

**可选 — MCP 入站表单(elicitation,E23):默认关**

```bash
MCP_ELICITATION_ENABLED=true       # 总开关,默认 **false**(fail-closed:不开则 MCP 服务器的表单请求根本不上场)
MCP_ELICITATION_MAX_CONCURRENT=2   # 每条 run 同时挂几张表单(默认 2,有界 [1,64])
MCP_ELICITATION_MAX_TOTAL=20       # 每条 run 一共能弹几张(默认 20,有界 [1,10000])
MCP_ELICITATION_MIN_INTERVAL_MS=1000  # 同一个 MCP server 两张表单之间的最小间隔(默认 1000,有界 [0,600000])
MCP_ELICITATION_TTL_MS=300000      # 一张没人填的表单挂多久后释放(默认 300000=5min,有界 [1000,3600000])
```
- 四个节流钮都是 `numEnvBounded`:**越界启动期响亮拒,不静默夹取**(一个手滑的多小时 TTL 会让表单实际上永不过期)。
- 总开关关着时四个节流钮解析照跑但无消费者——不设=行为不变。帧格式见
  [`docs/ASSISTANT-WIRE-CONTRACT.md` §4a-quater](docs/ASSISTANT-WIRE-CONTRACT.md)(`elicitation` / `elicitation_complete`)。

**可选 — 把这台 worker 挂进别人的 A2A 拓扑(server-as-peer,DESIGN-269 车2):默认关**

别家 orchestrator 把这台 sema-server 当成它拓扑里的一个 **A2A agent** 来调。开了才有两条对外路由;
关着(默认)它们**都不存在**(404)——不是「发卡但拒调」。

```bash
A2A_SERVE_ENABLED=true                       # 总开关,默认 **false**(operator-knob 律;坏词拒启)
A2A_SERVE_URL=https://agent.example.com/v1/a2a  # 【开了就必填】卡上公告的对外 JSON-RPC 端点
A2A_SERVE_NAME=sema-edge                     # 卡上的 name(默认 "sema-server")
A2A_SERVE_DESCRIPTION="…"                    # 可选
A2A_SERVE_SKILLS='[{"id":"review","name":"Code review","description":"…","scenario":"code-review"}]'
A2A_SERVE_BLOCKING_WAIT_MS=45000             # blocking:true 的服务端等待窗(默认 45000,有界 [0,300000])
```
- **`A2A_SERVE_URL` 开了就必填,且必须是可解析的 http(s) URL** —— 缺席/坏形**启动期拒**。可达地址是
  运维声明的,服务端**不从请求 `Host` 头推导**:那等于让一个匿名的发现请求决定我们公网卡上公告的地址。
  (同族先例:`FLEET_ADVERTISE_ADDRESS`。)🔒 URL **不许带 userinfo**(`https://user:pass@host/…`)——
  它逐字上匿名可拉的卡,等于把凭据发给每个发现请求;带了**拒启**。入站鉴权走**头形**(服务凭据)。
- **`A2A_SERVE_SKILLS` = JSON 数组,坏形拒启**(非 JSON / 非数组 / 条目缺 `id` 或 `scenario` / `id` 重复
  / 超长 / 超过 64 条)。声明面**没有**「丢掉坏的那条继续跑」的合法语义:那会让你以为公告了一个技能而
  对端根本看不到。每条 skill 的 `scenario` 是**内部**路由键 —— 它**不上卡**(自动枚举场景名给公网是
  本设计唯一点名禁止的事),只决定点名这条 skill 的请求跑哪个场景。
- 两条路由的**鉴权位置刻意不同**:`GET /.well-known/agent-card.json` 在服务凭据门**外**(公网匿名可
  发现,零存量披露);`POST /v1/a2a` 在门**内**(与本仓其余 API 同一套凭据)。
- `message/send` / `tasks/get` 还需要 **durable run store**(`DB_BACKEND=mysql|pg`)——缺它时两个方法
  回具名 JSON-RPC `-32004`(卡照发)。开关打开时 boot 日志有一行 `a2a_serve_enabled`,里面的
  `tasksUsable` 就是这件事。
- 完整 wire 契约(方法表、错误码全表、Task 形与任务态投影、反枚举语义、版本槽)见
  [`docs/ASSISTANT-WIRE-CONTRACT.md` 附录 C](docs/ASSISTANT-WIRE-CONTRACT.md)。

**可选 — 父进程存活监视(`SEMA_PARENT_PID`,默认不装配)**

壳(cli/TUI/桌面)自己 spawn 引擎的**同机形**里,壳被 `SIGKILL`(崩溃、`kill -9`、OOM killer)之后没有
任何清理钩子跑得起来:引擎被 reparent 到 init **继续活着**,占着端口、库连接池和模型配额,而且再没有人
会给它发 `SIGTERM`。设了这个键,引擎就自己盯着那个 pid:

```bash
SEMA_PARENT_PID=$$            # 壳把自己的 pid 传给它 spawn 出来的引擎
ENGINE_ORPHAN_LINGER_MS=2000  # 可选,默认 2s:成孤儿之后还要静多久才自退
```

`DB_BACKEND=local` 上还多一层痛:那只孤儿抱着 `<数据根>/LOCK` 不放(判定持锁人是否还在用的就是
`process.kill(pid,0)`,所以**活着的孤儿是合法持锁人**),于是此后每一只新壳起自己的引擎都撞
`another instance (pid N) owns this data dir` —— 用户看到的是"壳崩过一次以后就再也起不来了"。

- **缺席 = 整件不装配**(opt-in)。不设 = 和以前**逐字节一致**,存量部署零变化。systemd / launchd /
  容器托管的引擎本来就 reparent 到 1,**不要**给它们设这个键。
- **判据 = 两支,任一成立即把引擎标成「孤儿」**(拍频固定 **2s**):
  - **被过继走**(强身份):`process.ppid` 不再是引擎启动那一刻的那个值 —— 生我的进程没了,内核就把我
    过继给 init(或容器里的 subreaper)。判据刻意**不是** `ppid == 1`:收养者未必是 1,而且壳自己就是
    pid 1 的容器形里 `== 1` 会在开机第一拍误判一台健康引擎。
  - **pid 不存在**(弱身份):`process.kill(pid, 0)` 抛 `ESRCH`(不投递任何信号,只问"这个 pid 在不在")。
    **`EPERM` = 父还活着**(进程在,只是本进程无权给它发信号 —— 父跑在另一个 uid 下时很常见)。
- 🔴 **成孤儿 ≠ 停机**:这条腿**只探不判**。父没了之后引擎会不会退,由下一节那条**同一条**附着租约律
  说了算 —— 无附着 ∧ 无在飞 run ∧ 无非探针 HTTP 触点,**持续**满 `ENGINE_ORPHAN_LINGER_MS`(默认 2s)。
  也就是说:**有人挂着流、或者还有 run 在跑的时候,孤儿引擎继续服务**(`/health` 的 `draining` 仍是
  `false`,新提交照收);没人用了才退。多壳共享一只引擎(A 起、B 按 `engine.port` 复用、A 正常退出)时,
  B 的引擎不会再被推进排空(此前:`draining` 当场翻真 ⇒ B 的下一次提交吃 `503 + Retry-After`)。
- **单拍即判,不去抖**:两支读的都是内核直答,不是会抖的采样值。自退走的是**和 `SIGTERM` 逐字同一条**
  优雅排空腿(`DRAIN_GRACE_MS` 那条):in-flight 的 run 照常结算 / park,不是裸退出;收尾链里的
  `backend.close()` 才是把 `LOCK` 真正还回去的那一步。**部署面预算**:壳挨 `kill -9` 到引擎退干净
  (退出码 0)且 `LOCK` 消失,按 **≤10s** 用 —— 拆开看是「发现父没了 ≤2s + 孤儿窗 2s + 自查腿一拍 2s +
  停机收尾」,前提是那一刻没人附着、没有在飞的 run(有的话按设计**继续活着**)。**实测**:壳挨刀到
  引擎进程消失 2.0s、`LOCK` 归还 1.96s。最后那一段「停机收尾」本身还有自己的闸:`LOCK` 归还在收尾链
  尾部的 `backend.close()`,正常亚秒级,但卡死的连接池会被 `hardShutdown` 的 **15s 终局闸**兜住 ⇒ 最坏
  上界是「≤10s + 收尾闸」。自动重起的壳请按「撞锁就退避重试」写,别把 10s 当硬保证。
- **自退留痕 `<数据根>/exit-last.json`**:自退在 **drain 开始那一刻**就把判据写进数据根(`orphan` /
  `reason` / `idleForMs` / `attached` / `inflight` / `lingerMs` + `version` / `at` / `pid`)。壳的 exit
  watch 往往在流断之后才跑,只看退出码分不清"它自己退的"与"它崩了";读到这个文件就能把这个 pid 归入
  **预期退出**。退出码仍是 **0**,不另立专用码。引擎下次在同一数据根启动时读到它 ⇒ 一行 `exit_last_found`
  然后**删掉**(在场 ⇔ **上一次**退出是自退;它与 `crash-last.json` 是对偶的两只判别位)。
- **拍频不开旋钮**(2s 写死)。有真需求再议 —— 这里刻意不增殖一个只有一个人会调的键。
- 日志:装配时一行 `parent_watch_armed`(`info`,带 `pid` / `ppidAtArm` / `intervalMs`),成孤儿时一行
  `parent_watch_orphaned`(**`warn`** —— 非计划内的生命周期事件,带 `reason: "reparented" | "esrch"`),
  真退的时候才是 `engine_lease_auto_exit`(带 `orphan: true` + 同一个 `reason`)+ `draining_started`。
- 坏值(`0` / 负数 / 小数 / 非数字 / 超过 `2147483647`)**拒启并指路**:`0` 和负数在 `process.kill` 里的
  语义是**进程组**,超出 int32 的值 `process.kill` 根本收不下(会变成"armed 了但永远探不动")。要关掉就
  **不设**这个键。探活若给出既不是 `ESRCH` 也不是 `EPERM` 的 errno,会打一条(仅一条)**`warn` 档**
  `parent_watch_probe_error` 并按"父还活着"处理 —— 弱身份那一支此时是失效的(强身份支不受影响),那行
  日志就是它唯一的告警。
- 🔴 **已知残余(不收窄,两条)**:① 引擎**还在 boot**(监视腿装在 `listen()` 之后)时父就死了**且**成了
  **僵尸**(它自己的上级不回收子进程 —— 容器里的朴素 PID 1 是典型;终端 / launchd / systemd 不在此列)
  —— 强身份支没有"变化"可看,弱身份支被僵尸的 pid 表项骗住,两支都不触发;② 壳经一层 **wrapper** spawn
  引擎却仍传自己的 pid 时,wrapper 先退会让强身份支把一台壳还活着的引擎标成孤儿(约定是**壳直接 spawn
  引擎**,别把这个键设成非直接父的 pid)。这条腿是**尽力自愈**,不是强一致的父子生命周期绑定 —— 要强
  一致,该由壳侧开一条持有型管道并在断开时收尸。

**可选 — 附着租约自退(`ENGINE_AUTO_EXIT`,默认关)**

上一节那条腿盯的是「**生我的那个 pid** 还在吗」。多个壳会话共享**同一只**引擎时它问错了问题:首壳退了、
peer 还在用。这条腿问的是那个真正的问题 —— **还有没有人附着**。

```bash
ENGINE_AUTO_EXIT=true         # 壳 spawn 引擎时注入;服务器部署**不要**设
ENGINE_LINGER_MS=60000        # 可选,默认 60s,下界 1000(父还活着时的窗)
ENGINE_ORPHAN_LINGER_MS=2000  # 可选,默认 2s(父没了之后的窗;见上一节)
```

- **默认 `false` = 不自退**,存量部署逐字零变化。一台 k8s 上的 replica 半夜没人用是**正常低谷**,
  不是「该退了」;自退只对**壳自 spawn 的本机引擎**有意义,所以由壳在 spawn 时显式打开。
  🔴 自退腿在「本键为真 **或** `SEMA_PARENT_PID` 在场」时装配(S-306:两件并成一件)。本键关着而
  `SEMA_PARENT_PID` 设了 ⇒ 父还活着期间**永不自退**(窗 = ∞,与以前逐字同),父没了才用孤儿窗。
  两键都缺席 ⇒ 整件不装配,零定时器。
- **自退判据 = 三合取,且必须连续满**「当前窗」(父还活着 = `ENGINE_LINGER_MS`;父没了 =
  `ENGINE_ORPHAN_LINGER_MS`。判据一条,只有窗长两态):① 没有附着的 SSE 流;② 没有在飞的 run
  (与 `/health` 的 `inflight` **同一个读数**);③ 没有非探针 HTTP 触点(`/health`、`/metrics*` 不算 ——
  探针不是「有人在用」),**且**最后一条附着流断开也已满窗。任何一条不满足即把计时**清零**。
  (最后那半句不是修辞:一条活得比 15s 拍频还短的流整个生命周期都落在两拍之间,自查腿从没看见过它 ——
  只有在**关闭点**记一笔离场时刻,「已经没人附着满 N 秒」才是精确的。)
- **「run 在跑就不退」是结构性保护,没有旋钮可以关掉它** —— 最后一个壳关掉、后台 run 还在烧的场景里,
  引擎必须活到那条 run 落地。
- **`ENGINE_LINGER_MS` 防的是壳重启窗**:壳崩了/重启的 1-3s 里附着数确实是 0,窗太短就在用户眼皮底下
  把引擎杀了。窗内任何一次重连即清零重新起算。
- **实际等待 = `[窗, 窗 + 一拍)`**:最后一条租约灭在两拍之间时要到下一拍才被看见。方向是**保守**的
  (只会等更久)。**拍频由窗推导、不是第二个旋钮**:`min(15s, 生效得到的每一个窗)` —— 没装父监视的
  服务器形仍是 15s 一拍(一拍都没多跳),装了父监视的壳形按 2s 采样(否则「孤儿 2s 内退」这句承诺会被
  15s 的采样滞后压掉)。
- 判死后走的是**和 `SIGTERM` 逐字同一条**优雅排空腿:拒新提交(503 + `Retry-After`)→ 候在途腿跑完 →
  停机。in-flight 的 run 照常结算 / park,**不是**裸退出;而且因为是受控退,`crash-last.json` 会被清掉
  ⇒ 下次启动**不会**把这次自退误读成崩溃。
- 日志:装配时一行 `engine_lease_armed`(带 `lingerMs` / `orphanLingerMs` / `tickMs`;`lingerMs: null`
  = 这一档窗永不到期),自退时一行 `engine_lease_auto_exit`(带 `orphan` / `reason` / `attached` /
  `inflight` / `idleForMs` / `lingerMs`,孤儿还带 `orphanedForMs`),随后就是那条 `draining_started`。
  `reason` 是闭集:`"idle"`(没人用了)/ `"reparented"` / `"esrch"`(后两者 = 父没了那一路,`orphan:true`)。
  同一份读数会写进 `<数据根>/exit-last.json`(见上一节)。
- **坏值拒启**:`ENGINE_LINGER_MS` 必须是落在 **`[1000, 86400000]`(1s…24h)** 的整数毫秒,**两端都承重**:
  下界防 `ENGINE_LINGER_MS=60`(以为单位是秒)—— 60ms 的窗等于把整条防误杀腿静默摘掉;上界防 `1e100`
  这类值 —— 它能过朴素的「是正整数吗」检查,而计时**永远追不上它**,于是配置看着好好的、自退其实整件
  失效。两端一律**拒启并指路**而不是夹取。本键**没有**「关掉」的取值 —— 关自退的唯一开关是
  `ENGINE_AUTO_EXIT=false`。校验**不看** `ENGINE_AUTO_EXIT` 的表态:否则手滑值可以在一台今天关着自退的
  机器上一直躺着,等哪天有人打开时当场生效。
- 🔴 **已知收窄(成文,现版不收)**:**parked-待赎回** 的 run 不在判据里,只看 running。数 parked 要发
  SQL,而这是一只每 15s 无限期跑下去的定时器 —— 不值得为它引入周期性 SQL 轮询。parked 行是 **durable**
  的,引擎退掉不丢:下次起来(壳的下一次 spawn)仍可赎回,伤害面是**赎回被推迟**而不是丢失。
- 与 `SEMA_PARENT_PID` 的关系(S-306 起):**不是两件**,是**一件 + 一个输入** —— 那个键决定「父没了
  算不算数」以及此后用哪个窗,自退判据只有本节这一条。

**布尔旋钮的取值与极性(运维必读)**

布尔 env **收 `true` / `false` / `1` / `0` / `yes` / `no` / `on` / `off`**(大小写不敏感;词表自 7.16.0
起放宽)——`LSP_ENABLED=1` 这种人眼读作"开"的写法,现在代码里也
读作"开",不再是此前"看着开、其实关"的陷阱。词表**外**的值(如 `enabled`、`TRUE1`)⇒ 进程**直接
拒启**,报错点名该 env 名/收到的原始值/可接受词表(fail-loud,不留静默默认)。旧的「`config_env_
invalid_using_default` 警告 + 退回缺省值」这条臂随词表放宽一并退役,不再覆盖布尔旋钮——该事件现在
只用于其余种类的越界配置(如某个数值旋钮被夹到边界值)。

缺省值**推不出来**——同一个 `*_ENABLED` 后缀底下有三种极性。所以 `LOG_LEVEL=debug` 时服务每个旋钮打一行
`config_knob_polarity`:`knob` / `polarity` / `value` / `source`,直接告诉你这台机器上每个开关此刻是什么、为什么:

| polarity | 未设时 | 含义 |
|---|---|---|
| `opt-in` | **关** | 显式 `=true` 才开(实验面、有代价的面、危险面) |
| `opt-out` | **开** | 显式 `=false` 才关(缺省即最佳实践,给逃生舱) |
| `posture` | 取决于 `REQUIRE_PRINCIPAL` | 单用户 turnkey(未设 `REQUIRE_PRINCIPAL=true`)且基建就绪 ⇒ 开;多租户 ⇒ 关。显式字面量永远压过姿势推导 |

`source` 说明这个值从哪来:`env`(显式设了)/ `default`(缺省)/ `posture`(姿势推导)/ `legacy-env`(命中了
下面的兼容旧名)。表覆盖的是**本次加载中活着的旋钮**:挂在未激活通道/形状上的旋钮(非 k8s 通道下的
`K8S_INSECURE_TLS`、未设 `MODEL_DEGRADE_TO` 时的 `MODEL_DEGRADE_REACTIVE` 等)不出行——没有行=「在这份
配置里不生效」,不是「关」。

**改名(server 3.0.0 起旧名=fail-loud 墓碑:设了旧名直接拒启,错误文案指路新名。选拒启不选静默
忽略——静默会让升级部署的旧开关名义在、实际归默认,冒烟测不出来)**

| 🪦 墓碑旧名(设了即拒启) | 改设这个新名 | 缺省 | 说明 |
|---|---|---|---|
| `PROJECT_MEMORY_DISABLED` | `PROJECT_MEMORY_ENABLED=false` | 开 | host lane 项目记忆注入(CLAUDE.md + git 叙事) |
| `CONFIG_LKG_DISABLED` | `CONFIG_LKG_ENABLED=false` | 开 | 中心 effective 配置的 LKG 落盘(读写双关) |
| `HOST_BG_DISABLED` | `HOST_BG_ENABLED=false` | 开 | host lane 后台 shell 能力总闸 |
| `HOST_EXEC_SPOOL_DISABLED` | `HOST_EXEC_SPOOL_ENABLED=false` | 开 | host lane exec 的 spool 形 stdio(关掉=回退管道旧形) |
| `LSP_ENABLED=false`(拆分前用来关 host 腿那半) | `LSP_HOST_ENABLED=false` | 开 | 见下 |

四个旧名都是"负名"(`X_DISABLED`),读的时候要双重否定;新名一律正向 + 缺省显式。
🪦 **墓碑语义**:旧名**在场即拒启**——值是 `true` 还是 `false` 都一样(设 `false` 的人同样以为它还生效),
错误文案直接给出该改成什么。所以不存在"两个名字同时设"的状态,也不存在"旧名还在悄悄生效"的状态。

**`LSP_ENABLED` 一分为二**:它原来同时驱动两条腿,而且两腿缺省相反——沙箱腿缺省**关**(要烤好的
`ai-agent-code-lsp` 模板;⚠️ 该模板**只烤 TS/JS**——Python/Go 烤在 `dev-base`,dart/kotlin/java/swift
烤在 `dev-mobile`,四模板物理独立,`E2B_TEMPLATE` 选错则未烤语言的 LSP **静默退化到 grep/read**;
对照表见 ARCHITECTURE §7,真源=`src/lsp/e2b-bridge.ts`),host 腿缺省**开**(只要 PATH 上有 language server,没有就优雅退回 grep/read)。
现在沙箱腿仍是 `LSP_ENABLED`(opt-in),host 腿归 `LSP_HOST_ENABLED`(opt-out)。
🪦 `LSP_ENABLED=false`(拆分前唯一的 host 腿逃生舱)自 3.0.0 起是墓碑:拒启并指路 `LSP_HOST_ENABLED`。
`LSP_ENABLED=true` 不受影响——那是沙箱腿自己的 opt-in,语义没变。

**`CONFIG_REQUIRE_ROSTER=true`(3.1.0,可选,缺省关)**:远程 registry 非 dryRun 车道的 fleet worker
可布防「roster 落地前拒接计费提交(503)」——3.0.0 起 MODEL_ID 必填,每个 worker 都带 boot 模型起服,
旧的「无 env 模型即等 roster」推断失效;要那个保护语义现在需显式声明。

---

## 1. 请求与响应的通用规则

**所有 POST 都带:** `-H 'content-type: application/json'`

**鉴权 / 身份(两个不同的头,按需):**
| 头 | 什么时候必须 | 含义 |
|---|---|---|
| `Authorization: Bearer <SERVICE_AUTH_TOKEN>` | 设了 `SERVICE_AUTH_TOKEN` 时,所有非 `/health` 请求 | **谁有权调本服务**(OA 后端持有,服务到服务) |
| `x-agent-principal: user:42` | 设了 `REQUIRE_PRINCIPAL=true` 时 | **代表哪个终端用户**(决定 session 归属 + 记忆隔离;**绝不从 body 取**) |

| env 键 | 缺省 | 说明 |
|---|---|---|
| `PRINCIPAL_HEADER` | `x-agent-principal` | 上面那个身份头的**名字**。🔴 **缺省值是跨仓 wire 常量,不是给部署方自定义的**:这个名字**不在任何 wire 面上广播**(`/v1/capabilities`、`/health` 都没有它),所以没有下游能在运行期问 server「你叫它什么」——SDK 有一个 `principalHeader` 逃生舱但 cli/client-core 都没接线,浏览器端 BFF 是逐字硬编码,`mcp-oa` 侧车自己读同名 env(各读各的)。本旋钮只服务于「入口网关已经把身份写进别的头名」这一种特殊部署(让 server 迁就既有网关),不是给部署方起新名字用的。**改名即断下游**,但后果分四形(逐条对过代码):`REQUIRE_PRINCIPAL=true` 下提交腿(`POST /v1/tasks|/v1/runs`)是 `401` + **粗码 `auth.unauthorized`**(authorizer 抛的无 code HttpError 走状态码兜底映射),属主寻址的读/动词面是 `401 auth.principal_required`(路由级细码);两者的文案都逐字回显**你配的头名**,那是现场唯一能指认改名的线索。`REQUIRE_PRINCIPAL` 未开时更坏:**新会话照常成功**、每一条被当成**匿名**(session 归属 / 记忆隔离 / 规则车道 / operator 判定静默走无身份分支,无任何错误码),而**接续一条已属主的会话**会 `401`(`session is principal-owned; missing principal header …`)。⚠️ **SSO 与 direct-door 两条身份来源不经过这个头**(`verifiedPrincipal`),改名对它们无影响 —— 混合形部署因此会出现「一半调用方有身份、一半静默变匿名」的混着长。改了后果自负,且必须同批把每一家下游改到同名。取值须是**单个**合法 header 名(带空格/逗号 ⇒ 启动期拒启:它会撕裂 CORS `allow-headers`)。成文契约见 [`docs/ASSISTANT-WIRE-CONTRACT.md` §0.5](docs/ASSISTANT-WIRE-CONTRACT.md) |

> ⚠️ **Bearer 是"每个请求"，不只是 POST。** 配了 `SERVICE_AUTH_TOKEN` 后,**`GET /v1/runs/:id`、`GET /v1/runs/:id/events`(SSE)、`/metrics`** 等所有非 `/health` 路由都要带 `Authorization: Bearer`——漏带一律 `401`。下面示例为简洁**省略了 Bearer**，真实调用请逐个补上。

**请求体字段(你能传的全部):**
| 字段 | 必填 | 说明 |
|---|---|---|
| `objective` | ✅ | 要做什么(自然语言)。**唯一必填。** |
| `sessionId` | — | 续聊:带上次返回的 `sessionId`,服务端自动 wake 历史 |
| `images` | — | 图文输入 `[{data,mimeType}|{url}]`(模型需支持 vision) |
| `attachmentIds` | — | D-1 通用文件上传(1.289+):先 `POST /v1/attachments?name=…`(raw body,content-type=mime)拿句柄,提交时引用 ≤16 个;文件物化到执行环境工作目录 `attachments/` 下,objective 尾部自动追加文件清单(内容不进会话流)。单文件缺省 ≤32 MiB(`ATTACHMENT_MAX_BYTES`);可配 mime 白名单(`ATTACHMENT_MIME_ALLOWLIST` CSV,缺省不限);上传后未引用的按 `ATTACHMENT_UNBOUND_TTL_MS`(缺省 24h)回收。**云形态(tidb/pg)字节本体存对象存储——MinIO 必配**(`MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY`,与快照 lane 同一组变量),未配则附件面 501;local 形走本地文件店。 |
| `scenario` | — | 省略时取本部署的 `DEFAULT_SCENARIO`(工厂缺省场景名 = **`code`**——CC 编码 persona 蒸馏版,default 全量工具面,终验旋钮独立;`default` 场景**已非出厂缺省**,要用它须显式传 `scenario:"default"`,或部署方显式设 `DEFAULT_SCENARIO=default`)。可选内建名:`default`(通用/助理场景,域中性 persona,CC agent loop 全量工具面)/ `code-review`(见 §5)/ `scan`(同 §5 的 repo 只读工具但**中性无框架提示词**——objective+中心下发 skill 全权主导输出,OA 扫描类用;与 `code-review`/`discuss` 同属 clone-free 无执行环境场景,见 §5 末)/ **配置控制面可声明任意新场景**(`{name, toolset: none\|repo-readonly, prompt?}`,组合即配置、能力写死在部署;restart-to-apply;center 可覆盖内建名,boot 日志 `config_center_scenarios.shadowsBuiltin` 可审计) |
| `repo` / `council` / `debate` | — | `repo` 为 `code-review`/`scan` 必填;`council`/`debate` 仅 `code-review`,见 §5 |
| ~~model / tools / prompt~~ | 🚫 | **不接受**——服务端注入 |

**`TaskResult`(同步 / `done` 事件里拿到的;7.64.0 起终局是**一条** `terminal` 因由,不再有 `status`/`errorCode`/`errorMessage`/`blockedReason` 四个平面键):**
```json
{ "taskId":"…", "sessionId":"0190…",
  "terminal": { "kind": "completed" },
  "result":"最终回答文本",
  "stats": { "turns": 1, "tokens": 123 } }
```
`terminal` 四臂:`{kind:"completed"}` / `{kind:"failed", code?, message?, nestedPause?}` / `{kind:"blocked", reason}` /
`{kind:"paused", gate, checkpointId?, restoreMode?}`(wire 上**不带** `token`——那是 resume 凭据,永不出服务边界;用 `checkpointId` 对账、走 `/v1/approvals/:sessionId/decide`)。
| 字段 | 用途 |
|---|---|
| `result` | 答案文本 |
| `sessionId` | 续聊用——下次带回 |
| `terminal.kind` | `completed` / `blocked` / `failed` / `paused`(闭集;`timeout` 已于 core 5.8.0/server 6.0.0 退役——见 §9) |
| `terminal.code` | `failed` 臂的程序化分支:如 `"conflict"`(乐观锁丢失,可重试) |
| `terminal.reason` | `blocked` 臂:为什么做不了(缺信息/权限) |

> ⚠️ **任务成败以 `terminal.kind`/`terminal.code` 为准,勿以 HTTP 状态码判**:同步腿 200=提交受理成功(任务可能
> `terminal.kind:"failed"`+`code`);`POST /v1/runs` 202=已受理(执行期结局落在终态 run 记录/`done` 帧)。执行期
> 错误结构上无法用 HTTP 状态码承载——流的响应头在 run 开跑前就发完了(详见
> `docs/ASSISTANT-WIRE-CONTRACT.md` 附录 A)。

---

## 2. Hello world——同步发一个任务,拿到答案(最简单)
```bash
curl -s http://<host>:8090/v1/tasks -H 'content-type: application/json' \
  -H 'x-agent-principal: user:42' \
  -d '{"objective":"用一句话介绍 TiDB"}'
# → 200, 阻塞到跑完返回上面那个 TaskResult。result 就是答案。
```

## 3. 多轮对话——带回 `sessionId`
```bash
curl -s http://<host>:8090/v1/tasks -H 'content-type: application/json' \
  -H 'x-agent-principal: user:42' \
  -d '{"objective":"它和 MySQL 最大的区别是什么?","sessionId":"0190…"}'   # ← 同一个 sessionId
# → 服务端自动 wake 上轮历史。「恢复对话」= 同 sessionId 再发一次,无需调别的接口。
```

## 4. 生产姿势——异步(长任务 / 实时 UI / 断线重连,需 TiDB)
```bash
# ① 立刻拿 id(不阻塞,后台跑)
curl -s http://<host>:8090/v1/runs -H 'content-type: application/json' \
  -H 'x-agent-principal: user:42' -d '{"objective":"审查这个 PR …"}'
# → 202 {"taskId":"…","sessionId":"…","status":"running"}
#   同一 session 已有活跃 run → 409 {"error":"session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)","activeTaskId":"…"}

# ② 实时看进度(SSE,可断线重连:Last-Event-ID 从断点续)
#    注意:run 的 GET 是 owner 校验的——带上和发起时同一个 x-agent-principal,否则 404
curl -N http://<host>:8090/v1/runs/<taskId>/events \
  -H 'x-agent-principal: user:42' -H 'Last-Event-ID: 0'
#   SSE 每条三行:
#     id: 1                                      ← seq,断线重连就把它当 Last-Event-ID 传回
#     event: text                                ← 事件类型
#     data: {"type":"text","text":"…"}           ← 扁平 {type, …字段}
#   类型:text(按 turn 合并的文本)/ tool_start / tool_end / turn_end / compacted /
#         done(data 里有完整 TaskResult)/ failed
#   断线后:curl -N …/events -H 'Last-Event-ID: 12'  → 从 seq 13 续,不重复

# ③ 或者轮询最终结果(同样带 principal)
curl -s http://<host>:8090/v1/runs/<taskId> -H 'x-agent-principal: user:42'   # → {status, result, error?}
```
> 红利:②③ 经 LB 落到**任意一个 worker** 都能拿到同一个 run 的事件(数据全在 TiDB)。OA 不用关心是哪个 worker。

## 5. 代码评审场景(`scenario:"code-review"`)
请求体加 `scenario` + `repo`(`owner/name` 或 URL)。三档(成本/质量权衡):
```bash
# 直接评审(单 agent,快,默认):
-d '{"scenario":"code-review","repo":"your-org/your-repo","objective":"审 src/core/session.ts 的并发"}'
# council(L1 六镜头并行取证 + L3 仲裁,分桶 BUG/DESIGN/QUESTION):加 "council":true
# debate(再加 L2 平级辩论 + 工具核验,最高精度最贵):加 "council":true,"debate":true
```
服务端需配 `GIT_API_BASEURL` + `GIT_API_TOKEN`(只读,服务端持有);缺则 501,缺 `repo` 则 400。建议走异步 `/v1/runs`(council/debate 几分钟级)。

> **`GIT_API_BASEURL` 写法**:必须是**含 scheme 的完整 baseURL**(如 `https://git.example.com`),
> 不是裸主机名——服务端直接拼 `${GIT_API_BASEURL}/api/v1/...` 发请求,裸 `git.example.com` 会拼出非法 URL 而 fetch 失败。
> 末尾不要带 `/`,也不要把 `/api/v1` 写进来。

> **`GIT_API_KIND`(7.10.0 起)**:Git host 方言,闭集 `gitea`(缺省)/ `github`。GitHub 托管仓
> (github.com 或 GHE)设 `GIT_API_KIND=github`,此时 `GIT_API_BASEURL` 写 **API 根**(github.com 用
> `https://api.github.com`;GHE 用 `https://ghe.example.com/api/v3`),token 用 fine-grained PAT
> (仅 contents/pull-requests 只读)。未知词拒启;`GIT_API_BASEURL` 指向 github.com 族而 kind 仍是
> gitea 也拒启并指路(Gitea 形状对 GitHub 恒 404,启动即报优于运行时逐调用 404)。

> **⚠️ 升级注记(7.74.0):升级前把未决的 workflow park 决掉。** 本版起引擎对**任何**没有 `parks` 这一位的
> 历史 workflow run 记录,在派发任何 agent **之前**整条拒绝 `Workflow({resumeFromRunId})`
> (`workflow.park_truth_unreadable`,`detail.reason: "parks_unreadable"`)。那一位是本版引擎才开始写的,所以
> 所有**升级前**写下的 workflow run 记录都算数。这是刻意的:把旧记录读成「这条 run 没有 park」会让一只已经
> 停在人工审批上的子代被**重跑一遍**(付费 + 外部副作用)。**处置**:升级窗内把未决的 park 走
> `POST /v1/approvals/:sessionId/decide` 决掉,或对那些 run 起一条**新**的 run —— 不要 resume 旧记录。存量行
> 不需要清理(只在被 resume 时读,拒绝是逐次的、不留残留状态)。**本版不做兼容读腿。**

> **升级注记(7.7.0)**:本版起 `code-review` / `scan` / `discuss`(7.28.0 前叫 `team`)/ `toolset: repo-readonly|none` 的场景改跑
> 无执行环境的引擎。**升级前**若还有这些场景的 durable 挂起任务(需同时满足:配了 `REMOTE_EXEC`、开了
> `DURABLE_APPROVAL`、任务已 park),它们在新版上续跑会以 409 `checkpoint.unsupported_version` 响亮失败
> (run 行落 failed 并释放会话,不会占住会话)——重新提交该任务即可。升级前排空这类挂起任务可完全避免。

**clone-free 承诺(`code-review` / `scan`)**:这两个场景(含 `council`/`debate` 的镜头与仲裁子任务、以及
配置控制面用 `toolset: repo-readonly` / `none` 声明的场景)一律跑在**不带执行环境的引擎**上——
模型看到的工具面只有声明的只读仓库工具(`repo_tree` / `repo_read_file` / `repo_pull_diff`)加 `Now`,
**没有** `Bash` / `Edit` / `Write` / `Read` 等落盘工具,不 clone、不写盘、不起沙箱。
即使部署配了 `REMOTE_EXEC`(host/e2b/k8s/ssh/adb/local-docker)也如此:执行环境只属于 `default` / `code`
这类需要动手的场景。`discuss` 场景同理(协调者只调 `run_discussion`,成员是纯讨论人格)。

## 6. 逐 token 直播(同步流式,连接挂着)
```bash
curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
  -H 'x-agent-principal: user:42' -d '{"objective":"…"}'
# → SSE,每条 data: <原始 TaskEvent>,如 {"type":"text_delta","delta":"…"} … {"type":"done","result":{…}}
# 注意:这是「同实例逐 token」;跨 worker 断线重连用 §4 的 /v1/runs(turn 粒度)。
```

## 7. 其它接口(按需)
| 端点 | 用途 |
|---|---|
| `GET /v1/sessions/<id>` | 审计回溯:当前上下文 + 摘要(owner 校验) |
| `GET /v1/approvals?owner=user:42` | 高危写审批(durable,需 `DURABLE_APPROVAL=true`):**operator** 看待办队列(可按 owner 过滤);非 operator 只看自己的 |
| `POST /v1/approvals/<sessionId>/decide` `{"decision":"approve"|"deny","reason":"…"}` | 批/否并恢复挂起任务(CAS,重复决议 409);**仅 operator**(非 operator → 403)。5.0.0 起旧轮询腿 `POST /v1/approvals/<id>` 已退役 |
| `GET /v1/agents/roster` | 后台 agent **名册**(7.29 起,需 durable background-agent store,否则 501 `capability.background_agent_store_required`):调用方自己 scope 的 `a…` 行,新→旧。行只带 `handle`/`name`/`agentType`/`status`/`spawnedAt`/`updatedAt`/`settledAt`(content-free;子代产物正文走 `GET /v1/runs/<id>/subagents/<handle>/output`)。查询:`?limit=`(1..100,默认 20)、`?before=`(上一页的 `nextBefore` 游标)、`?status=`(running\|parked\|completed\|failed\|killed)、`?session=`(按会话树窄化)、`?scope=`(**仅** service-token/单用户形;带 principal 的调用方锁定自己)。回体 `{agents,total,nextBefore?,truncated?}`;`truncated:true` = 租户行数超过名册窗(500),`total` 此时是窗内数。非属主的行**不出现也不计数**(无存在性 oracle) |
| `GET /v1/capabilities/scenarios/<name>` | 单个场景的只读详情:工具面、提示词概览、**本部署现在跑不跑得动**(见下) |
| `POST /v1/admin/drain` `{"reason":"…"}` | **停机因由显式声明**(7.35.0+;operator-only —— principal 须在 `OPERATOR_PRINCIPALS` 内,空名单 = 谁都不是 operator ⇒ 恒 403)。设计给编排层(k8s `preStop` / systemd `ExecStop` 等)在发 SIGTERM **之前**调用声明因由;⚠️ **如实**:本仓四条消费树(server/cli/sdk/web-admin)与编排层脚本树 `sema-deploy` 里对它零调用,`sema-comms audits/.md:213` N-18 已把它书面登记为 declined(cli 判定这是 operator 面,不是壳自动打的口)—— 今天它是给**运维手动 curl**(或运维自己写的编排脚本)用的口,不是任何一个内置壳自动调用的。调用后 server 记进程内 `drainState.reason`,draining 期由 `/health` 的 `drainReason` 与 503 `draining` 体的 `reason` 透出(未声明 = 两面键缺席)。声明是**覆盖式**、无 TTL、随进程重启清零 ⇒ 每次停机流程内都要重新声明。`reason` 非空、trim 后 ≤256 字符,坏值 400。无 drain 面装配(嵌入宿主 / 测试)上声明无处可落 ⇒ 501 `capability.drain_state_required`。🔴 **写口是特权的,读口不是**:`/health` 免鉴权(见下一行)且缺省全网卡监听 —— 任何能连到这个端口的人都读得到你写的那句话。**别在 reason 里写工单号、内部主机名或任何内部标识**。 |
| `GET /v1/config/catalog` | **配置目录自描述**(operator-only —— `explicitOperatorOk`,空名单=谁都不是)。逐 env 键一行:`{name, domain, type, semantics, writeLanes, effectiveLane, envHeld, envTiming, staticDefault?/derivedDefaultNote?, effectiveValue, valueClass, danger, center?{domain,path,precedence,timing,restartSlice?,domainExists,managedNow,effectiveNow}}`;顶层带 `restartSlices`(重启片闭集)与 `centerDomains`(settings-schema 现行域表)。**两轴不混**:`writeLanes`=谁能写,`effectiveLane`=现在谁在生效(env-wins 族 env 占位 ⇒ center 腿 `effectiveNow:false`)。secret 类值**永不回传**(只回在场位;opaque 回在场位+长度;URL 剥 userinfo)。完备性由仓内 AST 双向对账门执法(分母=全 `src/` 生产 env 读取点)。旋钮 `CONFIG_CATALOG_ENABLED=false`(合规部署)⇒ 路径 404 + 能力位 `configCatalog:false`。响应 `Cache-Control: no-store`。**7.57.0 起**:`enum` 行的 `effectiveValue` **恒落在该行自己的 `enumValues` 闭集内**(仓内机器门执法)——此前 `SESSION_BACKEND` 回的是**内部标签** `tidb`(自 5.0.0 起是**退役公名**,写回去 boot 拒),现归一成公名 `mysql`;`MODEL_DEFAULT_THINKING` 补上一直被解析器接受、却漏在目录闭集外的 `xhigh`,并改为回**实算值**(`off`/未设 ⇒ `null`)而不是 env 原文。 |
| `POST /v1/admin/config/refresh` | **配置手动刷新**(operator-only,与上一行同门:空 `OPERATOR_PRINCIPALS` ⇒ 恒 403)。改完中心配置 / `config.d` 之后不想等 60s 轮询就调它:触发**一次既有**的 refresh 拍(在飞则汇入那一拍,绝不并发双拍),返回时该拍已落地。200 体 `{triggered:true,targetVersion,appliedVersion}` —— 两个世代号与 `/health` 的 `configTargetVersion`/`configAppliedVersion` 同源。本部署没有配置管道(纯 env worker:既没配 `CONFIG_CENTER`、也不是 `CONFIG_PROVIDER=local`)⇒ 409 `config.refresh_unavailable` 指路,不会回一个「触发了但什么都没发生」的 200。 |
| `GET /health` | 健康(无需鉴权)。**7.67.0+** 恒带 `startedAt`(引擎进程起点 epoch ms,一次铸;与 `pid` 并列的换代锚——值变了就是换了一条命)。7.37.0+ 配置管道在场时带世代账键:`configTargetVersion`(本副本**最后见到**的配置版本,身份标)、`configAppliedVersion`(最后一次真落地的版本)、`configTargetOrdinal`/`configAppliedOrdinal`(7.38.0-rc.2 起,**收货序数**——`CONFIG_PROVIDER=local` 下 version 是内容哈希不承诺序,「改了没生效」看序数对不对齐)、`configApplyStaleMs`(target≠applied **持续**时长;收敛时键缺席)—— 编排器据此摘掉「配置持续落后」的副本 |
| `GET /metrics` | Prometheus 指标(有 token 时需带) |

> **场景可用性(7.7.0 起)**:场景详情里 `enabled` 与 `available` 是**两件事**。`enabled` = 这条场景
> **被声明**了(内建恒 true);`available` = **本部署现在真跑得动**——后端依赖到位没有。不可用时另带一个
> **机读**原因键 `unavailableReason`(当前唯一取值 `git_client_unconfigured` = 没配 `GIT_API_BASEURL`,
> 命中 `scan` / `code-review` 这类只读仓库场景)。契约:`available:false` **⟺** 真发一次该场景的请求会
> 吃 **501**;`available:true` 时 `unavailableReason` **整键缺席**(缺席 = 没有理由,别读成空串)。
> 两面共用同一份判据,所以列表上画得出的场景点下去不会再突然 501。请按 `unavailableReason` 的**键**分支,
> 不要去匹配英文文案——文案会改,键不会(新增取值只会追加,现役键不改语义)。

> **operator 鉴权(审批队列)**:`OPERATOR_PRINCIPALS=ops:alice,ops:bob`(CSV)= 谁能当 operator——列任意 owner 待办 + 决议(批/否)。**单租户部署不设=旧行为**(握 service token 即 operator,向后兼容);设了之后,非名单 principal 列待办只看自己的、且**不能决议**(403,防"请求方批自己的高危操作"跳过 F4 闸)。⚠️ **多租户形拒启**:`DURABLE_APPROVAL=true` + `REQUIRE_PRINCIPAL=true` 而 `OPERATOR_PRINCIPALS` 空 ⇒ 进程启动失败并点名修法——否则空名单会让任一已验证租户读到其他租户的待批队列(读面 true-for-all)。设名单,或确属单租户则不设 `REQUIRE_PRINCIPAL`。
>
> **部署治理三声明(7.21.0 起)**——三根都是**配置期**旋钮,「谁能改它们」≡「谁能改
> 部署配置」,不引入新鉴权面;三根**都不设 = 现行为逐字不变**,坏值一律**拒启**(安全轴上的旋钮不静默失效)。
> - `COMPLIANCE_PROFILE=hipaa|zdr`(+ 可选 `COMPLIANCE_ADDITIONAL_DENIES=mcp_servers,workflows,web_fetch,org_memory_mount`,
>   **只能在档位内建 floor 上加禁,不能减**):合规档位否决。档位在场时被禁能力不挂载,任务**显式请求**被禁能力
>   ⇒ 响亮拒(prepare 期终态 `config.compliance_denied`)。hipaa 内建禁 `web_fetch`+`org_memory_mount`,zdr 内建禁
>   `org_memory_mount`。⚠️ 档位是**部署级**(对本部署所有 principal 同一份)——per-principal 档位需要中心侧下发面,
>   那一面尚未建;拼错档位名或能力名一律拒启(拼错=「标着 hipaa 却根本没在执行」)。
>   🔴 **装配相容性拒启**(不是配置洁癖,是保护):被禁能力若在本部署的装配里**恒在场**,引擎会**整拒**
>   每一条腿(不是静默不挂载),所以这两形在启动期就拒:①禁 `mcp_servers` 而部署自己配了 MCP 服务器;
>   ②禁 `web_fetch`(**`hipaa` 的内建 floor 就含它**)而本部署是**单用户**形——单用户花名册恒挂 WebFetch
>   (多租户形才会把它摘掉)。也就是说 **`COMPLIANCE_PROFILE=hipaa` 目前只能跑在 `REQUIRE_PRINCIPAL=true`
>   的部署上**;拒启文案会逐条说明改法。
> - `LOCKED_CONFIG_KEYS=mcp,compliancePosture,retentionPolicy`(闭集:`mcp`/`toolPolicy`/`compliancePosture`/`retentionPolicy`):
>   管理员锁定层。锁了 `mcp` ⇒ 任务自带 `mcpServers` 被**同步 400 整拒**(`errorCode:"config.locked_key"`,不静默丢)。
>   ⚠️ **本服务拒绝两把装不上的锁**:①`toolPolicy`——本服务每条腿都自铸有效工具策略(部署审批基线 + tighten-only
>   治理拍),锁上会让**每一条**任务在引擎门口被拒,而那把锁本就无物可守(策略已是部署所有、请求放松不了);
>   ②`mcp` + 部署自己配了 MCP 服务器——部署的服务器走同一个字段,锁上同样每条腿必拒。两者都在**启动期**点名拒,
>   不留给运维在每条任务上猜。
> - `RETENTION_MAX_AGE_DAYS=<非负数>`:托管留存**视界**(天;`0` 合法=立即可删)。坏值(非数/负数/设了但为空)
>   一律拒启——删数据的旋钮不许 NaN 流通。**它只是"保留多久"这句声明**;真正按它删数据的是下面两根。
> - `RETENTION_SWEEP_INTERVAL_SEC=<非负整数>`(默认 `0` = **关**;上限 `2147483` 秒 ≈ 24.8 天):
>   托管留存 **sweep lane** 的节律。`>0` 才起这条腿。越上限拒启(`setInterval` 的 32 位 delay 帽:溢出会被
>   Node 静默重置成 1ms,把一次"偶尔扫一遍"变成近乎连续的破坏性事务流)。
>   🔴 **半配置一律拒启**(不看有没有上锁):`>0` 而 ①没配 `RETENTION_MAX_AGE_DAYS`(没有视界就算不出 cutoff)、
>   或 ②后端不是 SQL(`DB_BACKEND=mysql|pg`;local/文件后端诚实声明 `retention:"none"`,没有任何东西会删行)
>   ⇒ 启动期点名拒。**多副本安全**:互斥靠库内单行 **sweep 租约**(fencing token 随每一条破坏性审计行走),
>   与 `LEADER_ENABLED` **无关**——不必先开 leader 面。
> - `RETENTION_MODE=audit-only|enforce`(默认 `audit-only`):灰度档。`audit-only` = lane 照跑照判、每个域记一条
>   `audit_only` 审计行(三个候选计数),**一条破坏性方法都不调**;`enforce` = 真删。闭集,拼错的词拒启
>   (静默落回默认档 = 运维以为在删而其实没删)。**回滚边界 = 翻回 `audit-only`**(已删不可逆),所以先在
>   `audit-only` 上读几天审计行确认命中集无误再翻。
>
>   **配套的 operator 面**(全部 `operator-only`,见 `OPERATOR_PRINCIPALS`):
>   - `PUT /v1/ops/retention/holds/:domain` / `DELETE …` —— 按域下/解 **legal hold**(冻结期该域整体跳过;
>     空路径段 `…/holds/` 寻址**无主桶**,即单用户部署里那唯一的域)。放置与解除各写一条审计行,
>     且**状态变更与审计行同一个事务**(不存在"解冻了但账上没有")。
>   - `GET /v1/ops/retention/audit?domain=&limit=&before=` —— 审计读面(keyset 分页,游标形同名册面)。
>   - 能力位 `capabilities.retention` = `{mode, maxAgeDays}`(lane 开着)或 `null`(关着)。
> - **子代转录(placed 分区)的留存天数没有独立旋钮座**(设计判定 2026-08-30):placed 转录的
>   删除权**独家**归联合 reap(`reapDurableAgents`:行赢删才 release 转录;上面这条 lane 的候选谓词显式排除
>   placed,见 `plugins/retention-store-sql.ts` 第五腿)——生效视界 = `BG_AGENT_RETENTION_MS`(缺省 7 天,
>   毫秒形,同时是 agent 行的留存旋钮;分区孤儿腿的 `olderThanMs` 同源)。core 的
>   `SUBAGENT_TRANSCRIPT_RETENTION_DAYS_DEFAULT`(30 天)是共享词表常量,**本服务未按它跑**;天数形专用
>   旋钮要等 core 在 reap policy 开出转录独立年龄座后再铸(届时名 `SUBAGENT_TRANSCRIPT_RETENTION_DAYS`)。
>   生效值上 boot 日志:`wiring_static.subagentTranscriptRetentionMs`。
> - `MEMORY_CONSOLIDATION_DRIVER=on|off`(默认 `off`):记忆**折叠(consolidation)阀门**。一个从未折叠过的
>   记忆库只涨不折,检索质量随库龄衰减;开这根旋钮就给 worker 装上两个 operator 口,让人在自己选的节奏上
>   跑折叠。⚠️ **闭集两词,别的一律拒启**——本旋钮是新的,不继承 `MEMORY_ENGINE` 的 `true/1/yes` 放宽:
>   一个被静默读成 `off` 的开词买到的是「运维以为在折叠、其实一次都没跑过」,而这条面的失败恰恰看不出来。
>   🔴 **`on` 而这台机器结构上跑不了 ⇒ 拒启**(四形各有点名文案):①没接记忆引擎(`MEMORY_ENGINE=off`,
>   或多租户形——记忆引擎只在单用户部署上装配);②记忆后端不自带引擎控制面归属(`MEMORY_ENGINE_BACKEND=file`
>   有,`pg`/`tidb` 没有 —— core 会把控制面落到副本本地盘,而一条 run 的续跑账死在 pod 重建上等于下次整库
>   重新蒸馏、真金白银);③`MEMORY_PROVENANCE=off`(折叠法必须铸得出 origin 标记,否则产物会不带标记提交);
>   ④**模型座位解析不出来**——既没有显式 chat 席,也没有 `roles.consolidate` / `roles.summarize`(含档位表的
>   `flash` 绑定)。core **刻意不回落主模型**(整库蒸馏不该悄悄骑最贵的席位),本服务照抄那条判断,不在下游
>   补一个兜底席。
> - `MEMORY_AUTO_CONSOLIDATION=on|off`(**未设 = 读引擎缺省**,见下):记忆**自动整理**的部署席
>   (core 7.21.0 的 `RunnerDeps.autoRunOnRecommendation` 席)。武装 ⇒ **每个产生了整理建议的任务
>   之后**,为该 scope 起**一次**整库整理跑 —— 🔴 **记忆内容自动外流到配置的整理模型**。
>   **三态,别读成布尔**:
>   - **未设** = 不写引擎那个键 ⇒ 读引擎的具名缺省,而**本版装的引擎那个缺省是「开」**。
>     ⇒ 🔴 **不配这根旋钮 = 武装**(在下面那条前置满足时)。缺省的属主在引擎侧、会随提货漂,所以
>     「什么都不配」这一态的含义**不是稳定的**;要一个稳定的答案就显式写一个词。
>   - `off` = **显式关,胜过缺省** —— 这是**关闭口**。不想让记忆自动外流的部署,靠它在 core 把缺省翻开
>     之后仍然保持关闭。**这是本旋钮存在的理由。**
>   - `on` = 武装。**设之前请先读**「这是出口决定不是性能旋钮」那一句:一轮 consolidation 读**全库**
>     (~1e5 prompt tokens)并把内容交给整理模型,既是钱更是**数据出境**。
>   ⚠️ **闭集两词,别的一律拒启**(与上一根同一条纪律,方向在这根上更要紧:一个被读成「未设」的拼错关词
>   会让那台部署跟着 core 的新缺省**静默武装**,而运维手里握着一份自以为关掉了的配置)。
>   🔴 **前置 = 上一根旋钮**:引擎面的整理两席(协议席 + 模型席)在场 **iff** `MEMORY_CONSOLIDATION_DRIVER=on`
>   且记忆引擎接了线 —— 两席与那两个 operator 口**共用同一只已解析座**(不做第二份装配)。
>   · 阀门关着 ⇒ 两席都不写键,引擎那半场**逐字节不变**;此时 `MEMORY_AUTO_CONSOLIDATION=on`
>     **启动期响亮拒**(core 对「武装但没接整理席」是**每一次 prepare 都抛** `config.auto_consolidation`,
>     留到运行期就是一台**启动成功、每个任务都死**的机器)。
>   · 阀门开着 ⇒ 两席齐 ⇒ 缺省(开)当场生效。
>   🔴🔴 **升级影响**:**升级前就配了 `MEMORY_CONSOLIDATION_DRIVER=on` 的部署,升到本版之后每个产生了
>   整理建议的任务之后会自动跑一轮整库整理**(升级前那是只能由人按的手动阀门)。不想要就**升级前**显式写
>   `MEMORY_AUTO_CONSOLIDATION=off` —— **不配不等于关**。启动日志 `memory_consolidation_driver_armed`
>   带一位 `autoRunOnRecommendation`(三态原样),用它核这台机器是哪一态。
>   **两根的分工**:上一根决定**有没有**整理能力,这一根决定那个能力**要不要自动触发**;关掉自动整理,
>   手动阀门照常可用。
>   未武装的部署,`wiring_manifest` 与它的 `configFingerprint` 对 7.20.1 **逐字节相同**;武装的部署,新段
>   `autoConsolidation: { onRecommendation: true }` **只出现在 operator 帧上**(契约附录 G.13)。
> - `MEMORY_CONSOLIDATION_SCOPES=user:local[,org:acme]`:阀门的 scope 表 = 这台 worker **声明**它会折叠哪些库。
>   **恰好一条**时它是两个口省略 `scope` 的唯一缺省;零条或多条时省略 `scope` 一律 400(在两个库之间替人挑
>   一个去花全库模型钱是本设计要消灭的形)。**表非空时显式 scope 必须在表里**,否则 400 —— 一个陈旧或敲错
>   的 scope 会把整库蒸馏的钱花在另一个库上并把它的条目标成 superseded。⚠️ 这是**运维安全网**,不是授权边界
>   (本服务没有任何授权源能对一个 operator 收窄 scope);表**空**时无从执法,显式 scope 照跑。
> - `MEMORY_CONSOLIDATION_INTERVAL_SEC=<非负整数>`(默认 `0`):周期腿节律。
>   🔴 **本版本没有周期腿,任何正值一律拒启**(不是"设了但不生效"——一台看着健康、库却永远不折叠的机器
>   正是这条阀门要消灭的形)。理由如实登记:真互斥要一把 durable 租约,而本仓唯一那把住在留存专用的 SQL
>   店里(`LEADER_ENABLED` 是纯布尔配置门,零选举零租约,拿它当互斥就是给运维一个假的独占承诺,而这条腿
>   并发跑的代价是重复的整库模型开销)。**要周期跑就用外部调度器**(cron / k8s CronJob)打下面那一口;
>   腿落地那天下界 `300` 秒会生效(拒启文案里已写明)。
>
>   **配套的 operator 面**(全部 `operator-only`,见 `OPERATOR_PRINCIPALS`;阀门关着时两口是 **404,不是
>   501** —— 消费端的动作是「让运维开旋钮」,不是「换部署」):
>   - `POST /v1/admin/memory/consolidation/run` `{"scope"?}` —— 跑(或**续跑**)一轮,同步回收执摘要。
>     ⚠️ **分钟级持久作业**:客户端超时后**原样重发**是安全的 —— 同副本上在飞的那一轮会被并入(单飞),
>     落定之后的重发走 core 的 durable run 行续跑(零新 mint)。**跨副本**仍是 at-least-once,别从两个地方
>     同时打同一个 scope。
>     ⚠️ 它**真的烧模型** ⇒ 与 `/v1/tasks` 同吃三道 503 门(drain / roster 未落 / 无 service 凭证)。
>     🔴 因此:**没配 `SERVICE_AUTH_TOKEN` 且未开 `ALLOW_UNAUTHED_WRITES` 的 worker 上这一口恒 503**,
>     而能力位仍报 true(本族共有缺口,附录 B.1 已如实登记)。启动日志为此打一条
>     `memory_consolidation_run_credential_gated`;状态读那一口不受影响(它不烧模型)。
>     🔴 另一条启动告警:`OPERATOR_PRINCIPALS` 为空时两口恒 403(空名单 = 没有任何人是 operator),
>     日志里是 `memory_consolidation_valve_unreachable`。
>   - `GET /v1/admin/memory/consolidation[?scope=]` —— 状态投影(阀门/座位/scope 表/上一轮的账)。
>   - 能力位 `capabilities.memoryConsolidationDriver`(阀门上了场 ∧ operator 名单非空)。
>   - 逐键语义与停因闭集见 `docs/ASSISTANT-WIRE-CONTRACT.md` §10。

> **⚠️ hook-wired 部署里,parked 后台子代可能赎回不了(常态,不是升级窗口)。**
> 引擎 5.19.0 起,一个任务的 **PreToolUse screening 面下延管辖它委派出去的子代**,于是 hook-wired 父
> 派出的子代 park 时,checkpoint 记的祖先约束层数是 **2**(screening 席 + 父自己的策略席);而本服务的
> 赎回腿重建得出的只有 **1** 层。引擎按**层数**做 pre-CAS 校验 ⇒ 每次赎回都被响亮拒
> (`resume.parent_constraint_mismatch`,checkpoint **保持 pending 不被消费**,不静默降级成更松的链)。
>
> **7.7.0(引擎 5.20.0)起 `TOOL_TRACE` 已退出射程。** 引擎 5.20.0 新增「这条 PreToolUse 面只观察、
> 不裁决」的声明口,本服务的诊断 tracer(`TOOL_TRACE=true` 装的那只,恒不出判词)已按实声明 ⇒ 它**不再
> 铸筛查席**,该部署的层数回到 1、赎回照常通。**开着 `TOOL_TRACE` 不再需要在「诊断」与「后台子代能不能
> 赎回」之间二选一。**
>
> 📎 连带的一处**日志**变化(不是回归):引擎那行「可写工具面没有 effect-aware 门」的启动告警,此前把
> 诊断 tracer 当成一道门而被抑制;声明之后不再抑制,所以「没配任何工具策略层 + 开着 `TOOL_TRACE`」的
> 部署升级后会多出那行告警。tracer 从来没有门住任何东西,原先的抑制本身才是问题。
>
> **仍在射程内的只剩一条**:调用方提交里带 `settings.hooks.PreToolUse`(未开 `REQUIRE_PRINCIPAL` 的部署
> 对外开放此面)。那是**会真裁决**的面(出得来 deny/ask),按契约**不能**打观察标——打了等于让引擎把
> 调用方的判词静默丢弃,比拒绝本身坏得多。这条面上的席位是每请求闭包、park 时未持久化,跨副本重建不出,
> 所以拒绝仍是正确行为。不带该字段的部署**完全不受影响**。
>
> **精确的兼容矩阵**(本服务恒供 1 层;引擎只比层数,所以下表就是全部情形。5.18.1 / 5.19.0 / 5.20.0 三个
> 引擎上都实测过):
>
> | 挂起的是谁 | checkpoint 是哪版铸的 | 父有没有**会裁决**的 PreToolUse 面 | 行里记的层数 | 本服务供的层数 | 结果 |
> | --- | --- | --- | --- | --- | --- |
> | **第一代**子代 | ≤ 5.18.1 | 任意 | 1 | 1 | ✅ 照常赎回 —— **升级本身不会弄坏存量行** |
> | **第一代**子代 | 5.19.0 | 否 | 1 | 1 | ✅ 照常赎回 |
> | **第一代**子代 | 5.19.0 | **是**,或**只是开了 `TOOL_TRACE`** | 2 | 1 | ❌ 永久拒(见下「没有恢复路径」) |
> | **第一代**子代 | ≥ 5.20.0 | 否(含只开 `TOOL_TRACE`) | 1 | 1 | ✅ 照常赎回 |
> | **第一代**子代 | ≥ 5.20.0 | **是**(调用方 `settings.hooks.PreToolUse`) | 2 | 1 | ❌ 永久拒 |
> | **嵌套**(孙代及更深) | 任意 | 任意 | **≥2** | 1 | ❌ 永久拒(5.19.0 之前就如此,历版无变化) |
>
> ⚠️ 嵌套那一行**不是恒等于 2**:引擎的子代链是「继承来的整条 + (有会裁决的 hook 就加一席) + 自己那一层」
> 逐层追加,所以嵌套与 hook 叠加时记的层数会**超过** 2(有钉实测:`test/parked-revive-e2e.test.ts` 的计数锚)。
> 对本服务而言结论一样(供 1,任何 ≥2 都拒),但**别把错误文案里的那个数字当成层深的可靠读数**。
>
> ⇒ **纠正一个容易想当然的说法**:上游 CHANGELOG 写的「升级前后跨版本 drain」对本服务**不是硬要求**——
> 旧行记的就是 1、我们供的也是 1,升级方向不产生错配(反向回滚同理)。滚版前把 `GET /v1/approvals` 排空
> 仍是好习惯(减少活过开关切换的行),但它**解决不了**下面这条。
>
> **没有恢复路径,只有预防旋钮。** 层数是 park 那一刻**写死进 checkpoint** 的:事后再批一次、事后改配置、
> 事后升级或回滚引擎版本,都不改行里记的 2 也不改我们供的 1 ⇒ **已经搁浅的行赎回不回来**
> (它们保持 pending 直到 TTL/reap;那次操作只能作为**新任务**重跑)。
> **这条对 7.6.0(引擎 5.19.0)期间开着 `TOOL_TRACE` 铸下的行同样成立**:升到 7.7.0 只让**此后**新铸的行
> 回到 1 层,那批老行仍记着 2,批不动——请把它们当作**新任务**重跑,或等 TTL/reap 收走。
> 今天仍有效的预防旋钮 = **在开放 `settings.hooks.PreToolUse` 的部署上不依赖后台子代的 durable 审批**
> (或用 `REQUIRE_PRINCIPAL` 关掉该面),同样只对**此后**新铸的行生效。
>
> **🔧 升级到 7.44.0 前:`PERMISSION_RULES_ENABLED=true` 的部署必须删库重建 `permission_rule_approval`
> 表(BREAKING)。** 卡编辑面(自由文本规则臂)给这张表新增 `command`/`edited_json` 两列,而三条
> 记录语句(`get`/`create`/`cas`)无条件引用它们。本服务的 schema 契约 = 启动 DDL 是唯一真源、不发
> `ALTER` 增量 seam,建表语句是 `CREATE TABLE IF NOT EXISTS` ⇒ 对已存在的旧表**一字不改**:新两列不在,
> 升级后**整条审批记录面**(包括修前就有的候选臂)在这台机器上是坏的——不是「少一个新功能」。
> 为此本版加了一道**拒启**探针(不是 warn):启动时对该表探两个新列,可证缺列 ⇒ 拒绝启动,错误文案
> 逐字指路 `DROP TABLE permission_rule_approval;`,重启后 schema 在 boot 时按新 DDL 重建。代价有界:
> 这张表只装**审批记录**(谁在何时同意了哪条候选的审计事实),丢的是在飞的 CC 导入预览(可重新跑一遍)
> 与历史记录轨迹;真正持久化的规则本身在 `permission_rule` 表,不受影响、不需要重建。
> 探针只在规则车道真被装配时跑(`PERMISSION_RULES_ENABLED=false` 的部署一次都不碰这张表,不受影响)。
>
> **同批顺带:场景旧名 `team` / 工具旧名 `run_team` 退役(BREAKING)。** 此前过渡窗的别名层
> (`SCENARIO_ALIASES`)整体撤销:请求体里显式传 `scenario:"team"` 从「静默归一到 `discuss`」变成
> **未知名响亮 400**(`scenario_unknown`);指派/部署配置里悬空写着的旧名走**现有**
> unknown→default 回退(与任何其他未知名同待遇,不是新逻辑)。prompts 里写死旧工具名 `run_team` 的
> 部署 ⇒ 模型看到「工具不存在」。滚版前把请求面/配置面/prompts 里的旧名换成现役主名
> `discuss` / `run_discussion`。
>
> **🔧 升级到 7.10.0 前:必须删库重建(BREAKING,两条同窗)。** ① pg 侧 memory 两表的
> `agent_memory_engine_entry.scope/slug` 与 `agent_memory_engine_cursor.scope` 由 `text` 收窄为
> `varchar(190)` / `varchar(512)`(与 MySQL-protocol 方言同宽);② 七个 epoch-BIGINT 列补 `_ms` 后缀,
> 两方言同窗改:`checkpoint.created_at|decided_at|terminal_at`、`workflow_resume_claim.claimed_at`、
> `workflow_run.ended_at`、`workflow_notify_journal.created_at|acked_at`。
> **本服务的 schema 契约 = 启动 DDL 是唯一真源,不发 `ALTER` 增量 seam**,而建表语句是
> `CREATE TABLE IF NOT EXISTS` ⇒ 对已存在的旧表**一字不改**:旧列名的存量表在新代码下每一次读写都直接
> 报错,**没有静默降级路径**。滚版前重建这几张表(或整库),基线见 `docs/schema/baseline-*.sql`。
> wire/HTTP 面零变化——改的只是列名,对外字段仍是 `createdAt` / `decidedAt` / `endedAt` / `ackedAt`
> 等 camelCase 形。
>
> **🔧 升级到 7.8.0 前:必须删库重建(BREAKING,SQL 命名三轴归一化)。** ①**表名单数化 9 张**:
> `approval_asks`→`approval_ask`、`approval_batches`→`approval_batch`、
> `background_agents`→`background_agent`、`mailboxes`→`mailbox`、`mailbox_messages`→`mailbox_message`、
> `task_list_items`→`task_list_item`、`agent_memory_engine_entries|cursors|sync_cursors`→
> `…entry|cursor|sync_cursor`(表限定形索引名的表段随改);②五个 epoch 毫秒 BIGINT 列补 `_ms` 后缀
> ×双方言:`checkpoint_ctx.updated_at_ms`、`workflow_journal.created_at_ms`、`workflow_run.created_at_ms`、
> `workflow_completion_inbox.enqueued_at_ms`、`agent_memory_engine_push_queue.next_attempt_at_ms`;
> ③OCC 词归一:approval 两表 `version`→`rev`。同上——`CREATE TABLE IF NOT EXISTS` 不改存量表,
> **旧表名/旧列名在新代码下读写即报错**,滚版前删库重建(零存量用户窗口,不做增量迁移)。
> wire 面零变化(approval 店的列名不出 wire)。
>
> **🔧 升级到 7.7.0(引擎 core 5.20.0)前:检查数值旋钮的写法。** 5.20.0 把「坏数值旋钮被接受、然后
> 悄悄做**相反**的事」这一类全部改成响亮拒或响亮钳位。两条与运维直接相关:
>
> - **写成 `1e9` / `1_800_000` 的毫秒旋钮会跳回字面值。** 引擎自己读的那五个 MCP **毫秒**旋钮
>   (`MCP_TOOL_TIMEOUT`、`MCP_TOOL_TIMEOUT_TOTAL`、`MCP_IDLE_TIMEOUT_STDIO`、`MCP_IDLE_TIMEOUT_HTTP`、
>   `MCP_TIMEOUT`)此前是 `parseInt` 语义:`1e9` 实际生效成 **1 毫秒**,`30s` 生效成 30 毫秒。升级后它们
>   按**字面值**生效并钳进 `[1000, 2147483647]` **毫秒**。
>   ⛔ **恰恰是这两种迁移写法不会有任何告警**(亲读引擎 `parseEnvMs` 确认):`1e9` / `1_800_000` 升级后
>   是**合法且在区间内**的值,于是既不钳位也不告警——旧部署上「1 毫秒」会**静默**跳成十亿毫秒 / 三十分钟。
>   告警只在两种情形打:值**读不成数**(整条忽略、回落内置默认)或**越出区间**(钳位后点名)。而且解析发生在
>   **首次真用到该 MCP 设置**时,不是进程启动时——所以「启动没看到告警」不代表没变。
>   ⇒ **升级前逐个人工核对这五个值,把非纯数字的写法改成纯数字**;别指望日志替你发现。
>   ⚠️ `MAX_MCP_OUTPUT_TOKENS` **不是毫秒旋钮**,别按上面那个区间去改它:它是 **token 数**,本次只是
>   换用同一套数字文法(此前 `1e5` 被读成 4;换文法后按字面值),**取值范围照旧不设上限**。
>   上面这组旋钮由**引擎**直接读 `process.env`,本服务不经手。本服务自己解析的数值旋钮走的是另一条
>   判据(不受本次变更影响):承重旋钮**非数字即启动失败并点名**、越界即启动失败,少数被显式标成
>   fail-safe 的可选旋钮回落默认值并打一行 warn。
> - **坏的 retention 旋钮升级后是「拒绝」,不是「清洗」。** 引擎的 `reap` 家族
>   (后台代理行 / 信箱 / workflow run / 两个花名册店)对**非有限或负**的界改抛 `config.retention_policy_invalid`。
>   症状是**每一次 reap 都抛、行只进不出**(此前 `NaN` 会塌成「删掉该 scope 下每一条终态行」,
>   花名册的 `maxAgeMs` 则让每个 durable 地址都读成已过期)。**先把旋钮改对再升级,引擎不会替你修**。
>   本服务的 `BG_AGENT_RETENTION_MS` / `WORKFLOW_RUN_RETENTION_MS` / `WORKFLOW_JOURNAL_RETENTION_MS` /
>   `ROSTER_RETENTION_MS` 在 config 层已是「非数字启动即失败、负值钳到 1 分钟下限」,所以经**文档化的
>   env 通道**配置的部署碰不到这条;它是给「自带注入式配置」的集成方与「看到这个错误码时怎么读」准备的。
>
> **🔧 升级到 7.5.0(引擎 core 5.17.0)前:把待决审批排空。** 5.17.0 起,park 铸行按**后端能承载的
> 宽度**落——审批人看到的 args / 预览、盘上躺着的行、resume 真正执行的那份参数,以及运维在 `/decide`
> 上要回显的那个不透明 `boundInputHash`,都从同一份投影铸出。**本服务的两条 checkpoint 后端
> (SQL 双生 / 本地文件)都是 JSON 序列化**,已按 5.17.0 的新轴显式声明 `fidelity: "json"`,所以对
> **JSON 值域**(模型产出的 args 恒在此域内)这次折叠规则变更是**逐字节零变化**:同一份 args 在 7.4.0
> 和 7.5.0 上算出的 `boundInputHash` 相同,已经发出去的哈希不需要重新取。
> 需要动作的只有一种行:**升级前就已经 pending 的那些**——它们带的是旧版本铸的哈希与(可能已降级的)
> 参数,新引擎不会追认改写。**滚版前把它们批/否掉**(`GET /v1/approvals` 列出来,逐个 `/decide`),
> 或明确接受那批老行仍按旧语义结算。新铸的行不受影响。
> **同一次升级还要重建 mailbox 两表。** 消息表新增 `hop_chain` 列(引擎的 peer 消息守卫),
> 而建表语句是 `CREATE TABLE IF NOT EXISTS` —— 对已存在的旧表**一字不改**,升级后每一次 teammate 消息
> 投递都会报 unknown column 并失败。按本服务的 schema 契约(删库重建、不做增量迁移),滚版时
> **重建 mailbox 两表**(或整库),基线见 `docs/schema/baseline-*.sql`。
> ⚠️ 表名按**你要升到的版本**读:7.8.0 之前叫 `mailboxes` / `mailbox_messages`,7.8.0 的单数化归一
> (见上「升级到 7.8.0」条)之后叫 **`mailbox` / `mailbox_message`** —— 后者是现役名。
> 该表是带 TTL 的短命投递队列而非账本,重建只丢排队中的 teammate 消息;介意就先让在飞的 peer 会话收敛。
>
> 顺带一提,新引擎会在铸点**直接拒绝 park**(点名后端、退回同步门)的只有两类值,而且都只可能由
> 部署侧的 hook / policy 改写进 args —— 模型自己给的参数永远是 JSON,碰不到任何一条:
> ① **拿不住的值**(函数、symbol、活句柄这些 `structuredClone` 复制不了的),以及 **`SharedArrayBuffer`**
> ——后者的理由不是「编不成 JSON」而是「克隆之后仍与原持有者共享同一块内存」,一行存下去别人还能改它,
> 所以在捕获性检查那一步就被拒;② **编不成 JSON 的值**(`BigInt`、循环引用)。
> 至于 `Date` / `Map` / 正则这类**能编码但会被 JSON 投影压扁**的值,不拒绝 —— 它们按投影后的形态入行,
> 若投影改变了值,引擎会拿投影后的那份**重新过一遍部署策略**再决定 park。
>
> **parked 后台子代的待办分两个 scope 桶**(durable 审批面,core 1.389 起):父任务显式转发审批范围的常规 ask 落在该范围的 scope 下(可预算);无转发时无人值守拦下的敏感操作 ask 落在按 principal 派生的隔离 scope 下(带缺省 deadline、永不自动放行)。operator 全量列表天然两桶全见;**按 `?owner` 过滤时注意两桶可能不同名**,展示面要两个都查。

---

## 8. 三类调用方怎么接

**A. OA 系统(服务到服务后端)**
- OA 后端持有 `SERVICE_AUTH_TOKEN`,每个请求按当前终端用户设 `x-agent-principal: user:<id>`。
- 短问答 → `/v1/tasks`(同步);长任务/要进度条 → `/v1/runs` + `/events`(异步)。
- 续聊:把上次 `sessionId` 存在 OA 会话里,下次带回。

**B. 智能体客户端直接调(把本服务当一个"委派工具")**
- 最简单:`POST /v1/tasks`,body `{"objective":"<要委派的子任务>"}`,拿 `result` 当工具输出。
- 要让 agent 自己审仓库:`{"scenario":"code-review","repo":"…","objective":"…","council":true}`。
- 多轮:agent 维护 `sessionId` 即可保持上下文。

**C. 你自己的简单入口**
- 就是上面的 `curl`;或跑 `./smoke.sh http://<host>:8090 user:42` 一条命令端到端验。

---

## 9. 状态 / 错误码速查
| 你看到 | 含义 | 怎么办 |
|---|---|---|
| HTTP `200` + `terminal.kind:"completed"` | 成功 | 取 `result` |
| `terminal.kind:"blocked"` | agent 主动报卡住 | 看 `terminal.reason`,补信息再发 |
| `terminal:{kind:"failed", code:"conflict"}` | 跨实例乐观锁丢失 | 直接重试(幂等) |
| `terminal:{kind:"failed", code:"limits.max_walltime_exceeded"}` | 墙钟到限(6.0.0 起 `status:"timeout"` 退役;7.64.0 起平面 `status` 键退役)。🔴 **7.71.0 / 引擎 7.13.0 收窄**:上限若在最后一轮 turn **已经给出干净答案之后**才响,run 记 `completed`、答案就是 `result`,**不铸本码**——那次停机的唯一痕迹是 `engine_notice` 的 `task.interrupt_unconsumed{origin:"walltime"}`(附录 D.3)。本码此后只在**工作被真切断**时出现。⚠️ 按码计数的运维面要把 `origin ∈ walltime\|turns` 的那一帧计入才守恒(server 侧对偶计量 `limit_ceiling_after_answer_total{origin}`,与 `budget_exceeded_total{code}` **相加**才是撞顶总量) | 拆小任务 / 提高 `limits.maxWalltimeMs`(毫秒) |
| `terminal:{kind:"failed", code:"limits.max_turns_exceeded"}` | 轮数到帽。🔴 **同上收窄**(引擎 7.13.0):帽子若落在**那一轮答案自己**身上 ⇒ `completed` + `task.interrupt_unconsumed{origin:"turns"}`,不铸本码 | 提高 `limits.maxTurns` / 拆小任务 |
| HTTP `401` | 缺 `Authorization` / 缺 `x-agent-principal`(要求时) | 补头 |
| HTTP `403` / `404`(session/run) | 不是该 principal 的资源 | 用正确身份 |
| HTTP `409`(`/v1/runs`) | 同 session 已有活跃 run | 等它完成 / 用返回的 `activeTaskId` |
| HTTP `429` | 限流 | 看 `Retry-After` 退避 |
| HTTP `429` + `errorCode:"limit.cost_quota_exceeded"` | per-principal 累计成本配额越顶(`MAX_PRINCIPAL_COST_USD`;**进场门**,不打断在跑的 run) | 看 `Retry-After` / 体 `retryAfterSec` 退避;窗滚过或调高上限后放行 |
| HTTP `429` + `errorCode:"usage.window_exhausted"` | 部署级治理窗耗尽(`USAGE_WINDOWS`,token 或 $ 天花板先满者) | 看响应体 `retryAfterSec` 退避;窗滑动/桶到期后放行 |
| `terminal:{kind:"failed", code:"usage.window_exhausted"}` | 已受理的 run 在 **turn 边界**撞上治理窗且无法 durable 挂起 | 同上退避后重投;配好 checkpoint 基建则改为 `suspended` 等窗自动续跑 |
| HTTP `501`(`/v1/runs`) | 内存模式不支持异步 | 配 MySQL 协议存储(`SESSION_BACKEND=mysql`) |

**机器码**:每个 4xx/5xx 响应体都带一个 `errorCode`(与人类文案 `error` 并列),这是**唯一**该拿来做
程序分支的字段——**别锚 `error` 文案**。前缀族固定:`auth.` / `request.` / `not_found.` / `conflict.` /
`limit.` / `usage.`(部署级治理窗)/ `capability.`(本部署没接这个面)/ `feature.`(开关没开)/
`internal.` / `state.`;未知码按前缀兜底永远安全。完整码表见 `docs/ASSISTANT-WIRE-CONTRACT.md` §附录 A。

**进程警告面(日志采集方注意)**:引擎(core ≥5.16)在持久 agent 行的 org 判决写回被丢弃等场合走
Node `process.emitWarning`,警告文案前缀固定 `sema durable-agents:`——采集规则按该前缀匹配,别当噪音过滤掉。
server 自身的兜底留痕走 stderr 契约行 `[sema] fail-open: <tag>`(逐 tag 一次)+ `fail_open_total` 指标,
tag 词表与每条的最坏后果见 `docs/FAIL-OPEN-CENSUS.md`。引擎装配期忠告(core `onError(phase:"config")`)落
`runner_config_advisory` 日志行,带结构化 `code`(如 `config.toolpolicy.unmatched_names`)与 `classification`
(core 给宿主定级的判别词;词表外原样透传并标 `classificationKnown:false`),指标 `runner_config_advisory_total`
按 `code` / `classification` / `level` 打标;其中 `shell-gate-off` 在本 session 显式声明 `permissionMode:
"bypassPermissions"` 时是调用方要的姿态,降为 **info** 并标 `expected`——只有没表态落到引擎默认 off 的无壳直连
形才 warn。

🔴 **`shell-gate-off` 这条注的语义自 7.73.0(core 7.15.0)起收窄,文案由引擎改写**:`off` 档**不再**意味着
「这条会话的 shell 完全没有门」。**读边界**(内建读拒表 + 工作区容纳面)现在**每一档都判**,`shellGate` 只管
**剩余**风险 ⇒ `off` 车道下一条读拒表路径(`cat .claude/settings.json`)、工作区外路径,或递归读形
(`grep -r … .` / `find .` / `ls -R` / `du .`)都会产**恰一个 mandated 审批**(规则与 auto 分类器都清不掉);
**非读**命令(`rm` / `curl` / `git` / `npm`)与工作区内的普通读在 `off` 下仍然零审批。注文案里那句
「the read boundary is still judged on every command」就是这件事。⇒ **运维侧后果**:一台把 `bypassPermissions`
当「无人值守直跑」用的部署,现在会在这三类命令上落审批(无活体席 ⇒ durable park,出现在 `GET /v1/approvals`)。
要让它们重新不问,得从**读面**下手(`READ_FACE` / `READ_DENY_BUILTIN_TIERS` / `READ_DENY_BUILTIN_EXCLUDE`,
见 `docs/DEPLOY-PREREQS.md`),`permissionMode` 那根杆子够不着它。

### 9.1 limits.approachNotice(6.1.0 起)

引擎默认在上下文用量到 80%/95% 时各注入一次接近提示(SSE 上是 `steering_injected` 帧、
`source:"limit_approach"`)。批处理场景嫌噪声:`limits:{approachNotice:false}` 关闭;调阈:
`limits:{approachNotice:{at:[0.5,0.9]}}`(两分数 (0,1] 且 r1<=r2,相等合法;坏形 400 点名)。

### 9.1b total_tokens 余量提醒帧(缺省翻面 —— core 5.65 系起)

声明了 token 上限(`limits.maxTokens` 或资源切片余量)的任务,**每个带工具调用的边界**现在会多一行
CC 逐字节形的提醒帧:`<total_tokens>N tokens left</total_tokens>`(**缺省 ON**;此前需显式 opt-in)。
- **谁受影响**:声明了 token 上限的部署——模型看得见余量倒数;未声明上限的任务**恒静默**(core 只发布
  它已在强制执行的那道上限,不猜上下文窗)。
- **退出旋钮**:提交体 `attachments: { totalTokensReminder: false }`;档位经
  `attachments.totalTokensReminderMode`(`off`/`infinite`/`fixed`/`countdown`/`padded-countdown`,
  缺省 padded-countdown;词表外值 400 拒不折默认)。
- **分辨依据**(消费端/日志过滤):该帧内文**恒以 ` tokens left` 结尾**;`Infinite` 常量臂(`infinite`
  档)是唯一例外,单独放行——它不带数字、不依赖上限,且只在显式 `true` 下可达。

### 9.1c `TASK_TIMEOUT_SEC` 的真语义:`0` 不是「关」,正值**只抬不降**(7.57.0 起成文)

这根旋钮此前在配置目录里写着「0=关」——那句**只在单用户部署成立**,而且漏了它的第二条纪律。
真判据在 `taskWallClockSec`:

- **`0` / 未设** ⇒ 走 **tenancy 基值**:单用户 turnkey(`REQUIRE_PRINCIPAL` 未开)= **真无墙**;
  多租户 = **2400s**(大任务 council/debate/discuss = **3600s**)——墙**是开着的**,不是关的。
- **设了正值** ⇒ `max(值, 基值)` = **只抬不降**。所以在多租户上写 `TASK_TIMEOUT_SEC=60` 拿到的是
  **2400**,不是 60(设计如此:误配的低值不许砍掉 council 预算)。要**更紧**的配速走提交体
  `limits.maxWalltimeMs`——那是 caller 的显式意图,直接生效,可以低于 env 墙。
- **负值** ⇒ **启动即拒**(7.57.0 起)。此前 `-1` 被静默折成 `0` = 「按 tenancy 走」,运维想关墙、
  在多租户上反而拿到 2400s 的墙——方向相反且零提示。非数值(`3600s` 这种带单位后缀的写法)照旧拒启。

### 9.2 部署级治理窗(USAGE_WINDOWS,6.1.0 起;$ ceiling=core 7.0.0 起)

跨任务的滑动预算(core `usageWindows`),缺省不设=关。单键 JSON 数组;每窗两只**独立** ceiling
(`maxTokens` token 数 / `maxCostUsd` 绝对美元)——each optional、**至少其一**(双缺拒启),
whichever fills first 即耗尽,缺席的 ceiling 不参与(非 0、非 ∞):

```bash
USAGE_WINDOWS='[{"windowMs":18000000,"maxTokens":5000000,"anchor":"first-use"}]'   # 5h 窗 500 万 token
USAGE_WINDOWS='[{"windowMs":604800000,"maxCostUsd":25,"anchor":"rolling"}]'        # 7d 滑动窗 $25(纯 $ 窗合法)
USAGE_WINDOWS='[{"windowMs":18000000,"maxTokens":5000000,"maxCostUsd":10,"anchor":"first-use"}]'  # 双 ceiling:先满者停
# anchor:"first-use"=首次消费开桶、到期重开;"rolling"=滑动窗(最近 windowMs 内的消费之和)
```

- **key 语义是 per-key 而不是一口总锅**:带 principal 的请求计到各自 principal 的窗,匿名请求计到
  GLOBAL 锅——`REQUIRE_PRINCIPAL=false` 的混流部署下,总放行量=(principal 数+1)×maxTokens。要 fleet
  级总闸请用 quota/lease 面(那是按部署计费轴)。同一调用方有时带凭证有时不带,会在两口锅间劈叉计数。
- 耗尽:提交面立即 `429 usage.window_exhausted`(体 `retryAfterSec` + `Retry-After` 头);已受理的
  任务撞窗时,基建合格(checkpoint+durable approval)⇒ suspend(reason `usage_window`)等窗放行,
  不合格 ⇒ 响亮失败同码落终态记录。
- 计量覆盖:主任务、具名/委托 subagent、hook 内部任务全计(部署真花销都进窗)。**leader 车道自 7.56 起同覆盖**
  (`LEADER_ENABLED` 的 planner / worker / repair / conflict 四类任务与主车道同席同店,且**记到提交者那口锅**
  ——leader 的每条 TaskSpec 都带提交者身份,匿名部署落 GLOBAL 锅;`POST /v1/leader` 的四门集合、门序与拒的
  字节与 `/v1/tasks`、`POST /v1/runs` 一致 —— 窗耗尽同样 `429 usage.window_exhausted`,且**在起后台腿之前**拒,
  被拒的提交一个字节都不会推到远端)。≤7.55 这条腿两面皆缺:既不受窗约束,花销也不入窗。
  ⚠️ 一处**如实登记的差异**:`POST /v1/runs` 的幂等重放预读在窗门之前,leader 的幂等裁决在门之后 ⇒ 窗满期间
  用**同一个** `Idempotency-Key` 重投拿到的是 429 而不是原来那张 202 收据。这不丢 run:429 **不消费键**,窗一
  放行,同键重投照旧回原 `leaderRunId` 且不会起第二条后台腿(第二条 = 第二次真 `git push`)。
- **$ 窗(core 7.0.0 起)**:`maxCostUsd` 要求 run **可计价**——模型既无 pricing 条目也无
  `Model.cost` 声明时,cost 不是 0 而是**不存在**,$ 窗对它不可求值 ⇒ 任务门口拒
  (`config.usage_window_unpriced`),绝不记 0 让天花板静默失效。MIXED 声明(部署里同时有 $ 窗与纯
  token 窗)下共享账本行统一携带 cost;全部署零 $ 窗时账本行与此前逐字节同形。
- 坏形 `USAGE_WINDOWS` 拒启(fail-loud 点名第几项);窗对象是**闭集**四键 {windowMs, maxTokens?,
  maxCostUsd?, anchor}——未知键(含拼错的 `maxCostUSD`)拒启点名,绝不静默降级成 token-only 治理;
  **改窗形≈重开账**(first-use 桶按 windowMs 配对,改了窗长老桶下次记账即弃)。
- 存储:SQL 后端=跨副本一致(`usage_window` 表,行锁守恒);local=文件;纯内存 dev=进程内。

### 9.3 沙箱寿命声明(6.1.0 起)

k8s 沙箱(pod `activeDeadlineSeconds` 真硬死线)向引擎声明 `lifetimeMs`——死线前 ~60s 引擎在 turn
边界尝试 suspend(reason `env_lifetime`,基建合格时)而不是被平台杀在半途;单个 >60s 的工具调用仍可能
冲过该窗口,声明是尽力座不是保证。e2b 沙箱有 keepalive 滚动续期、无固定死线,不声明(行为不变)。

### 9.4 auto 模式(`permissionMode:"auto"`):武装三态、本地 org deny、自查(与 core 的意图武装式合修)

**背景**:此前引擎只在「组织授予」(config-center 解出的 per-principal `runtimeCaps.autoMode === true`)时武装
auto 分类器,而无 center 的本地/自托管部署没有任何授予路径 ⇒ 壳选了 auto,引擎跑的仍是 default + 静态只读
白名单(真实用户诊断属实)。core 的新武装式对齐 CC 的「用户开 + 组织拒」极性:

```
armed ⟺ 本次请求 permissionMode 是 "auto"  ∧  本部署装配了分类器席位  ∧  runtimeCaps.autoMode !== false
```

**`runtimeCaps.autoMode` 三态**:`undefined`(没配 center / center 未配这个 principal)= **不阻**;`true` = 不阻;
`false` = **组织拒**。来源三条,都落同一个位:① center 对该 principal 显式 `false`;② center 配了但**硬失败**
(fail-closed 拒项同车铸 `false`,与 workflows/fork 同向,`denyTtl` 窗内不抬起);③ 本地旋钮:

| 旋钮 | 缺省 | 语义 |
|---|---|---|
| `PERMISSIONS_DISABLE_AUTO_MODE` | `false`(opt-in) | CC/center/settings 键名 `permissions.disableAutoMode` 的 **env 腿**(config catalog 登记 center 键名 `permissions.disableAutoMode`,settings-schema 现无 `permissions` 域 ⇒ 目录行 `domainExists:false`):**tighten-only** 棘轮 —— `true` ⇒ 本部署每个 principal 的 `caps.autoMode` 折成 `false`(center 授予 `true` **翻不回**);未设 ⇒ 不动 caps。布尔词表(`true/false/1/0/yes/no/on/off`),词表外的值**拒启**(安全轴旋钮不许静默回默认)。目录行见 `GET /v1/config/catalog`(approval 域,security 轴) |
| `CROSS_SESSION_INBOUND` | 未设 | 跨终端会话设计线:CC settings 键名 `crossSessionInbound` 的**部署/组织层**(引擎层形的 `managed` 层,CC `policySettings` 对位)—— 本部署对**入站跨会话消息**的治理三态:`accept` 投递 / `hold` 停在收件会话的待审队列(模型看不见、不能据此行动)/ `refuse` 本部署整体退出这条车道。🔴 **未设 ≠ `accept`**:未设 = 这一层不表态,由其余层与引擎的 mode-parity 判定决定。词表外的值**拒启**。⚠️ **生效前提**:引擎的 peer 目录席(`RunnerDeps.peerDirectory`)在场——本版**未接**该席,故本旋钮今天「就位待命」不生效(目录席到货那天零改动即生效);目录行见 `GET /v1/config/catalog`(approval 域,security 轴) |
| `CROSS_SESSION_DIALOG_EXPIRY` | 未设(⇒ 引擎缺省 `5m`) | 同族:CC settings 键名 `dialogExpiry` 的部署层 —— 被 hold 的跨会话消息等待人审多久后按**安全缺省**结算(过期丢弃,**带回执**告知发送方,不静默吞)。词表 `60s`/`5m`/`10m`/`never`(`never` = 不设期限);未设 ⇒ 本仓**不铸键**,由引擎落它自己的缺省(不复制上游缺省值)。词表外拒启。⚠️ 生效前提同上。另注:CC 的同名键还有第二个消费面(转发到远端客户端的审批对话框停靠时长),本旋钮**今天不驱动**那一面 |

**自查读面**(`GET /v1/capabilities.permissionModeAuto`,壳的 `sema doctor permissions` 消费;下例第一行是
`intentArming:true`(与带新武装式的 core 同批提货后)的读数——server 7.57 × core 7.2.0 上无 center 的本地部署答
`intentArming:false, armed:false, reason:"deployment_incapable"`,见 `reason`/`intentArming` 条):

```
GET /v1/capabilities?permissionMode=auto
→ permissionModeAuto: { accepted:true, classifierSeat:true, entitlementSource:false, intentArming:true, armed:true, model:"default" }
GET /v1/capabilities?permissionMode=default
→ permissionModeAuto: { …, armed:false, reason:"mode_not_auto" }
```

- `armed`:按上式此刻会不会武装(`?permissionMode=` 折进本次意图;查询串缺席 = 按 auto 意图答「若请求会不会武装」;
  五词之外 400 `request.field_invalid`)。
- `reason`(未武装时,闭集**六词**,判序即武装式合取序):`mode_not_auto`(意图不是 auto)/ `deployment_incapable`
  (装配面没挂分类器席位——生产装配恒挂,桩与一次性 CLI 不挂;**或** `intentArming:false` 且本部署没配 center:
  旧式「组织授予」极性下零授予路径,真武装不了)/ `org_denied`(上面三条来源之任一,以及 `intentArming:false` 下
  center 在场但本 principal 未被授予——旧极性自己的「缺席=未授予」)/ `local_denied`(本地旋钮 `PERMISSIONS_DISABLE_AUTO_MODE=true`,
  改本机 env)/ `settings_denied`(settings 层 `permissions.disableAutoMode:"disable"`——本面不产,归 run 级面)/ `resolver_fault`
  (per-principal resolver 抛错,引擎同形全拒)。
- `intentArming`:这对 server+core 走哪套极性,由安装包探针答——`true` = 意图武装式(无 center 也能 auto);`false` = 旧式「组织授予」
  (npm core 7.2.0 下 `false`:此时 `armed` 仍是**当前真值**——center 授予了的 principal 答 `true`,因为它
  真在武装——且**不看查询串**,旧式 core 不读 permission mode,授予即武装,`mode_not_auto` 只在新式下出现;无 center
  的本地部署答 `false`/`deployment_incapable`)。
- `model`:分类器**配置**路由(role 回落序 `classifier → summarize → default` 的 catalog 名),不是本次真用的模型。
- `entitlementSource` 仍只说「center 源在不在」;新武装式下它为 `false` **不再**意味着 auto 不武装。
- 读面按调用方身份解 per-principal caps(与引擎同一只 resolver,per-principal 缓存 60s;center 硬失败时受 8s
  超时 + 10s deny 缓存界)。

**settings 层 kill-switch(件⑥)**:请求体 `settings.permissions.disableAutoMode` 受理 **`"disable"` / `true` / `false`** 三形(两套已发布契约的拼写:CC 250 的严极词 `"disable"`,与 `@sema-agent/sdk` `SettingsPermissions.disableAutoMode?: boolean`);受理集之外的值 400 `request.field_invalid`。
`"disable"` 与 `true` **同义**:把该 run 的生效模式由 auto 折成 default(座不写 ⇒ 不武装;tighten-only,只能关不能开);`false` = 显式「不禁」= **合法且无效果**(本键没有放宽臂——它不是 auto 的开关)。`disableBypassPermissionsMode` 本批不落点。

**per-run 面**:本批不在 run 记录上加 per-run `autoMode` 座——引擎侧的武装结果 / 未武装原因(含只有引擎知道的那些;core 7.12.0 起熔断族已退役)
将随 core 的状态座到货,届时 server 只投影不自算;此前 `armed` 是 server 侧三项可知判据的裁决。

### 9.4b plan 模式(`permissionMode:"plan"`):批准一份计划时,顺带说「解到哪一档」(7.86.0 起)

只读起步的任务把计划交给人之后停在 `plan_review` 门上。`POST /v1/assistant/tasks/:id/plan_review` 的
`decision:"approve"` 可携 **`permissionModeAfter`**(闭集 `"default" | "acceptEdits"`,**缺席 ⇒ `"default"`**):
`acceptEdits` = 继续跑时对自己工作目录内的写不再逐次征询,`default` = 有手但每次写照常征询;`edit` / `reject`
的语义是重做计划,只读**原样保留**,在它们上送这一位是 `400`(词表外的值、以及本来就不是只读起步的任务送它,
同样是 `400`)。解除会写回这条会话的续跑输入 —— 此后每一次挂起→续跑都沿用解除后的档,不回落只读。
🔴 **这个体是闭集(7.86.0,S-438)**:受理集 = `decision` / `editedPlan` / `reason` / `permissionModeAfter` /
`boundCallId` 五键,集外键(含定谳前的旧拼法 `nextPermissionMode`)**响亮拒** `400 request.body_shape` +
机读两桶逐字点名,**不再静默丢** —— 与 `/v1/approvals/:id/decide` 同一只机器、同码同串。
逐字契约见 `docs/ASSISTANT-WIRE-CONTRACT.md` §4c。

### 9.5 Web search:两车道优先级、per-request `settings.webSearch`、错误形

**两车道,整段整取(不是逐键 merge)**:部署 env(`WEB_SEARCH_*`,§0)与 per-request `body.settings.webSearch`
是唯二两条配置来源。当 per-request 那条腿"生效"(见下面的受理门槛)时,它产出一个**全新、完全独立**的
`WebSearchBackendConfig`,**整只替换**掉 env 配出来的 backend —— 不是把 per-request 给的字段一个个覆盖到
env 的 config 上,env 的其余字段(尤其是 `WEB_SEARCH_TIMEOUT_MS`)**不会**被继承到 per-request 那次调用里
(`src/capabilities/scenarios.ts:264-266`):

```
const reqWebSearch = deps.requirePrincipal !== true
  ? webSearchConfigFromSettings(req.settings?.webSearch)
  : undefined;
const webSearch = reqWebSearch ? createWebSearchBackend(reqWebSearch) : deps.webSearch;
```

`reqWebSearch` 非 `undefined`(即 `settings.webSearch.provider` 是合法词**且**本部署是单用户车道)时,
`deps.webSearch`(env 配的 backend)整个不参与这次请求 —— 谁赢是**二选一**,不是字段级合并。

**受理门槛(单用户车道)**:🔒 per-request `settings.webSearch` 只在 `REQUIRE_PRINCIPAL !== true`(单用户 /
TOC 本地形)时被读取;`REQUIRE_PRINCIPAL=true`(多租户)上这个键**结构性够不着**——不是被拒、也不留任何
`warn`/`capabilities` 位说"你发的 webSearch 被忽略了"(对比 `mcpServers` 有 `capabilities.mcpInjection` +
`mcp_injection_dropped` 日志可读;`settings.webSearch` 没有对应的能力探测位,`src/http/wire-types.ts:320-323`)。
🔴 **别把 S-382 的新位读成这个探测位**:`GET /v1/capabilities` 的 `webSearch.backend`(S-382)说的是
**部署默认后端是哪一只**,**不是**「我发的 per-request `settings.webSearch` 会不会被采纳」——后者在多租户上
依旧是结构性够不着、无 warn、无位,这一段的结论一字未变。
原因是安全边界(与多租户能力配置隔离规则同源):per-request `endpoint`/`apiKey` 是能力配置,多租户下一个租户把 `searxng`
指向内网地址就是 SSRF,所以多租户上只认部署 env 配的 backend。

**`settings.webSearch` 键表**(`src/plugins/web-search.ts` `webSearchConfigFromSettings` §318-335;类型契约见
`@sema-agent/sdk` 9.0.0 `settings.d.ts` `SettingsWebSearch`):

| 键 | 类型 | 必填 | 默认 | 消费点 |
|---|---|---|---|---|
| `provider` | `"brave"｜"tavily"｜"searxng"` | 是(缺席/非三词之一 ⇒ 整个 `settings.webSearch` 被丢,回落到 env 的 backend,**不报错**) | 无 | `webSearchConfigFromSettings` 的词表守卫(与 env 腿**同一只** `isWebSearchProvider`,词表属主 = `WEB_SEARCH_PROVIDERS`) |
| `apiKey` | string(明文) | brave/tavily 建议带(缺了首次调用才报错,见下表);searxng 不读 | 无(不继承 env 的 `WEB_SEARCH_API_KEY`) | `webSearchConfigFromSettings` 的 `apiKey` 条件展开 |
| `endpoint` | string | searxng 必需(缺了首次调用才报错);brave/tavily 可选覆盖 | 无(不继承 env 的 `WEB_SEARCH_ENDPOINT`) | `webSearchConfigFromSettings` 的 `endpoint` 条件展开 |
| `searxngParams` | `Record<string,string>`(对象)或 `"k=v;k=v"`(字符串) | 否 | 无 | `webSearchConfigFromSettings` 的 `searxngParams` 臂,解析逻辑与 env 腿共用 `parseSearxngParams`。⚠️ **未列入已发布的 `@sema-agent/sdk` 8.8.0 `SettingsWebSearch` 类型**(该接口只有 `provider`/`apiKey`/`endpoint`/`maxResults` 四键、无开放下标)——server 侧代码认这个键,但当前发布的 TS 类型接不到它;手写 JSON 请求体仍可以发,server 会照常解析(源码头注自述:字段和消费点先落地,配置录入面尚未跟上,是一笔尚未还清的既有债务) |
| `maxResults` | number | 否 | 10(与 env 同一 clamp,`[1,20]`,小数向下取整) | `webSearchConfigFromSettings` 的 `maxResults` 归一 |
| *(无)* `timeoutMs` | — | — | — | **per-request 车道没有这个字段**——即便部署用 `WEB_SEARCH_TIMEOUT_MS` 配了非默认超时,per-request 生效那次调用永远退回 backend 自己的默认(10000ms,下限 1000ms),因为"整段整取"意味着 env 的 `timeoutMs` 根本不在 `reqWebSearch` 那个新对象里 |
| *(无)* `fetchImpl` | — | — | — | 同上,仅测试注入用,per-request 车道不可达 |

**门槛以外的键**:`settings.webSearch` 本身是 `TASK_SETTINGS_KEYS` 闭集里的受理顶层键
(`src/task-settings.ts:309`,集外顶层键 400 `request.body_shape`),但 `webSearch` **自己的子键没有闭集门**
——不像 `settings.permissions.*` 有 `TASK_SETTINGS_PERMISSION_KEYS` 逐键拒(`src/task-settings.ts:367-403`
的 `taskSettingsKeyIssue` 只扫 `settings.*` 顶层和 `settings.permissions.*` 两层)。`settings.webSearch` 下
塞一个上表之外的键(拼错的 `mxResults` 之类)**不会** 400,会被 `webSearchConfigFromSettings` 静默无视
——门槛之外没有"未知键"这一说。

**错误形**(逐条对应任务书里的四问;全部经**首次真实工具调用**才现形,均为 WebSearch 工具的
`tool_result` 错误文本,回到模型的对话里,**不是** HTTP 层 `errorCode`,**不会**使 `TaskResult` 整体
`failed`,也不拒启/不拒收请求本身;core `dist/tools/web.js:840-919` `createWebSearchTool` 统一兜底):

| 触发条件 | 现象 | 是否 retryable(core `classifySearchFailure`) |
|---|---|---|
| 坏 `provider`(`settings.webSearch.provider` 非三词之一,或缺席) | **不是错误** —— `webSearchConfigFromSettings` 返回 `undefined`,整段回落到 env 配的 backend(env 也没配 ⇒ WebSearch 工具不装配,模型看不到这个工具);无 warn、无日志 | 不适用 |
| 缺 `apiKey`(brave/tavily,env 或 per-request 均未给) | 首次调用抛 `WEB_SEARCH_API_KEY is required for the <provider> provider`,core 接住转成 `Error (WebSearch): the search backend failed. …` 文本回模型 | `"unknown"`(消息里没有 HTTP 状态码模式,`classifySearchFailure` 落最后一条默认分支) |
| `searxngParams` 非对象/非字符串(如数字、布尔、`null`) | 静默丢弃——`parseSearxngParams` 落到"非 object 且非 string"分支,返回 `undefined`,键整个不铸,**不报错、不 warn** | 不适用 |
| ⚠️ `searxngParams` 是**数组**(如 `["engines=bing"]`) | `typeof [] === "object"` 让它落进对象分支:`Object.entries` 按数组下标产出 `{"0":"engines=bing"}`——**不被拒绝**,但产出的键是数字字符串、值是未拆分的原始 `"k=v"` 串,传给 core adapter 的 `extraParams` 后是一组没有意义的查询参数(不是解析出 `engines=bing`)。这条边角只在 per-request 车道可达(env 值恒为字符串,不会触发);已用与源码逐字一致的独立复现脚本核验(见收车档),未改代码 | 不适用(不是异常路径) |
| 搜索后端返回**非 2xx HTTP 响应**(鉴权失败/限流/服务端 5xx 等) | 三个 provider 各自的 `!res.ok` 分支抛错,消息形固定为 `"<provider> search failed (<status>): <body首 200 字符>"`(brave/tavily,`braveSearch` / `tavilySearch` 的 `!res.ok` 分支)或 `"SearXNG <status> <statusText> from <url>"`(searxng,core adapter) | ⚠️ **实测几乎恒为 `"unknown"`,不是按状态码分档**:`classifySearchFailure` 的状态码分支要求 `http`/`status`/`code`/`error` 四词之一紧邻数字前(`\D{0,12}`内),但本仓三个 adapter 的消息把状态码写在 `failed (…)`/`SearXNG …` 之后,不触发该分支;独立复现脚本核验(见收车档)brave/tavily/searxng 的 429/500/403/403 全部落到最后一条默认分支 `retryable:"unknown"`。**真正被分类对的只有两条兜底正则**:上游错误体文本里若真含 `"rate limit"` 才判 429 类 `true`,含 `"timed out"`/`"timeout"` 才判 408 类 `true`——都取决于上游返回的具体措辞,不是本仓能保证的 |
| `endpoint` **网络层**不可达(连接被拒/DNS 解析失败/fetch 自身抛错,尚未拿到任何 HTTP 响应) | `fetch` 抛出的传输层错误(`ECONNREFUSED`/`ENOTFOUND`/`fetch failed` 等 Node/undici 标准措辞)被同一 `catch` 接住 | `true`——这条路径的错误文本天然含 `econnrefused`/`enotfound`/`fetch failed`/`dns` 等词,`classifySearchFailure` 的网络故障正则能命中(与上一行"已拿到 HTTP 响应但非 2xx"是两条不同的失败路径,别混淆) |

⚠️ **"坏 provider 静默回落"这条对调用方是否可观察,取决于部署 env 有没有配 backend**:上表第一行说
"env 也没配 ⇒ 工具不装配、模型看不到这个工具"——那只是**部署 env 同样缺席**这一种情形。若部署 env
**已经**配了合法 backend(例如 `WEB_SEARCH_PROVIDER=brave`),调用方 per-request 传一个拼错的 `provider`
(如 `"searx"`)、或带着一个本想打到自建 SearXNG 的 `endpoint`,`settings.webSearch` 整段被丢弃、静默回落
到 env 的 brave backend——**WebSearch 工具照常挂载、照常可用**,查询实际发给了 env 配的 provider,不是
调用方以为自己指定的那个;工具存在这一事实本身**不能**证明 per-request 的 `provider`/`endpoint` 真的
生效了,两种情形(per-request 生效 / per-request 被静默丢弃回落 env)在壳侧不可判别(此条经独立复现验证,
见收车档)。

**`apiKey` 明文与 env 槽边界**:server 收到的 `settings.webSearch.apiKey` 是**明文字符串**,没有任何服务端
密钥槽位/引用间接——收到什么字符串就直接进 backend 闭包(`webSearchConfigFromSettings` 的 `apiKey` 条件展开,与 `WEB_SEARCH_API_KEY` 同一条消费路径,
见 `src/plugins/web-search.ts` 的 `WebSearchBackendConfig.apiKey` 字段注)。**server 本批不改受理面**:明文字段的形状维持原样。调用方(壳)如何在
自己机器上管理这份明文是调用方的事——例如 cli 壳侧的约定是在**调用方自己的环境**里按
`SEMA_WEBSEARCH_KEY_<PROVIDER>` 这样的命名空间存放每个 provider 的 key,由壳在本地读出后把明文塞进请求体;
这纯粹是**客户端约定**,本仓不读取、不校验、也不感知任何这类客户端侧 env 命名(这条命名事实来自任务书,
本车未读 cli 源码核实,列入收车档「未闭环」)。

### 9.6 trace 脱敏的射程:与出站请求体无关

🔴 **脱敏 ≠ 出站控制**:trace 脱敏(账本 / SSE / turns / 审批预览里的 `«redacted»` 标记)只作用于
**持久化与展示面**——**工具结果发往「该 run 实际选中的模型路由」的请求体是原文**。⚠️ 「实际选中的路由」
不等于 `MODEL_GATEWAY_BASEURL` 一根:本部署的模型出口至少四条——默认网关(`MODEL_GATEWAY_BASEURL`)、
备用网关(`MODEL_GATEWAY_FALLBACK_URLS`,failover 拓扑参与)、`provider:"anthropic"` 模型的**独立直连
路由**(缺省根 `https://api.anthropic.com`,除非显式配 `ANTHROPIC_BASEURL`)、以及配置中心可对**单个
目录模型**下发的 per-model `baseUrl` 覆盖(见 `docs/ARCHITECTURE.md` §10「Trace/redaction」)。要让内容
不出机器,靠**读侧敏感路径门**(`READ_DENY_PATTERNS` 等,见 §「执行 lane 与租户门」附近)并核对**上述
四条路由每一条**都指向自己控制的基础设施——只把 `MODEL_GATEWAY_BASEURL` 指到私有网关、放着
`provider:"anthropic"` 或某个目录模型的 `baseUrl` 覆盖不管,那部分流量仍走公网原厂,不靠
`src/trace/redact.ts` 这个脱敏器——它自述「不是穷举的」,即便是穷举的也只管持久化/展示这一面(且它同时
被项目记忆注入、hook asyncRewake 唤醒文本这两条**反方向**调用复用——那两条是"先脱敏、脱敏结果才给模型
看",与工具结果这一条方向相反,验法时别混)。

**怎么验(运维口令)**:用自控转发代理截获**每一条生效路由**的出站体核对——不能只把 `MODEL_GATEWAY_BASEURL`
指向自己起的转发代理,还要按部署实际配置的路由数量逐条起代理(默认网关一个 + 每条备用网关一个 +
`ANTHROPIC_BASEURL` 一个,如果 `provider:"anthropic"` 在用);代理记录经过的每个请求体后原样转发给真
网关,跑一条会产出敏感格式(如一个明文 token)的工具结果,比对代理记下的出站 `messages[]` 里
`role:"tool"` 的内容与该 run 的 trace/SSE 拷贝:出站体应为**原文**,trace 拷贝应为 `«redacted…»`。两者
不同,即验证「脱敏只发生在持久化/展示面,不发生在出站面」这句话为真——**若代理本身继续把请求转发到外部
模型服务,即便截获成功,也不满足「内容不出机器」这个更高的目标,截获只证明了脱敏的射程,不证明数据没有
离开你控制的边界**。
