@sema-agent/settings-schema 8.3.1 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,137 @@
6
6
  > `@sema-agent/registry-core`(更早还曾是 `@sema-ai/registry-core`)。本档 `0.19.0` 以下各节里的
7
7
  > 包名是**历史记录**,如实保留不追改。
8
8
 
9
+ ## 10.0.0 — 2026-10-09 — 私钥 PEM 头认串中任意位置(BREAKING:写入校验认串中任意位置的私钥 PEM 头;读侧同套 schema ⇒ 存量私钥串的条目被丢 / 域 throw)
10
+
11
+ > **判档 = major**(README「发版纪律」:校验收紧、让同一份配置读写出不同结果 ⇒ major)。破坏的是**持久数据面**,不是类型面:导出面登记表零 diff(没有新增 / 删除 / 改形的导出),
12
+ > 但读侧与写侧共用同一套 schema,存量配置里已经带着私钥串的部署,升级后读侧丢条目 / 整域 throw(下面「谁受伤」)。硬 breaking:无别名、无开关、无兼容读。
13
+
14
+ ### 一、病
15
+
16
+ 校验侧探测器 `containsSecretToken`(经 `scanForSecret`)读私钥 PEM 头只认**串首**:头里带空格,按令牌切开的那一遍永远隔不出它;头在串首时靠 `SECRET_LITERAL_RE` 的行首锚分支。
17
+ `"x -----BEGIN PRIVATE KEY-----"` / `"key: -----BEGIN RSA PRIVATE KEY-----"` / `"…\n-----BEGIN OPENSSH PRIVATE KEY-----"` 因此都答 `false` —— 凡走这只探测器的自由文本写口,都能把整把私钥写进配置、
18
+ 进域行 / 变更历史 / 发布快照 / `/effective`。展示侧 `redactSecrets` 读的是同一只 `PRIVATE_KEY_PEM_HEADER`,却早已在串中任意位置、任一归一化形上判阳 —— **写与显两只判官对「头在串中」这一格答案不一致**。
19
+
20
+ ### 二、改(一处)
21
+
22
+ `scanForSecret` 加一条:私钥 PEM 头(`PRIVATE_KEY_PEM_HEADER`,`-----BEGIN[A-Z0-9 ]*PRIVATE KEY`,大小写不敏感,既有全部变体 —— 裸 `PRIVATE KEY` / `RSA` / `EC` / `DSA` / `OPENSSH` / `ENCRYPTED` / `PGP …`)在串中**任意位置**命中即判阳。
23
+ `containsSecretToken` 对原文与前三层百分号解码都跑 `scanForSecret`,所以编码过的头(`x%20-----BEGIN%20PRIVATE%20KEY`,三层之内)同样命中。**同一只正则**:展示侧在读的就是它,包内没有第二份(`src/` 里非注释行出现私钥头字样的只有那一处定义,有测试钉着)。
24
+ 公钥 / 证书头照旧不命中;其余令牌形(`sk-` / `ghp_` / JWT / Bearer / AKIA / AIza …)一字不动。`SECRET_LITERAL_RE` 的源串逐字不变(它是导出的单令牌字面形,下游据它的源串生成表)。
25
+ `redactSecrets` 行为不变(同三形读数仍是整串 `[redacted-secret]`);它头注里「`containsSecretToken` 刻意不在这里放宽」那一句随之改真,`containsSecretToken` / `PRIVATE_KEY_PEM_HEADER` 头注里「只认串首」的说法同改。
26
+
27
+ ### 三、一处改全部受益的写口(逐个核过,都在测试里灌同一张表)
28
+
29
+ 经 `containsSecretToken` 的写口(源:`src/types.ts`、`src/remote-exec.ts`):
30
+ - `ModelEntry.extraBody` / `compat` / `headers` —— 经 `vetFreeFormJson`(`extraBodyUnsafe` 描述符走查读每个字符串值与每个键;序列化克隆再扫一遍);
31
+ - URL 面 `NON_SECRET_URL`(路径 / 查询 / 片段,`urlQueryFragmentHasSecret`):`ModelEntry.baseUrl`、mcp http `url`、worker `coords.gitApiBaseUrl`、会话镜像引擎 URL、`remoteExec` 的 `apiUrl` / `s3Snapshot.endpoint`、中心凭证 `issuer`;
32
+ - mcp stdio `command` / `args[]`;
33
+ - 插件源 `PLUGIN_ORIGIN`;
34
+ - `remoteExec` ssh 的 `host` / `username`;
35
+ - `WorkerSpec.env` 值;`runtime.execution.coords` 值(含远端 docker 守护进程的 `DOCKER_HOST`)与 `runtime.store.db` 的 `host` / `user` / `database`(车道 / SQL 坐标);
36
+ - 授权覆盖值 `EntitlementOverride.value`(串,或 JSON 序列化后的串)。
37
+
38
+ 不受影响:HTTP 头名(字符集本就不收空格,私钥头不可能写进去);`ENV_NAME` 字段(字符集与 AKIA 形判据,不走这只探测器)。
39
+
40
+ ### 四、三问
41
+
42
+ - **谁需要**:把凭据误写进自由文本键的部署 —— 例如把整把私钥贴进 `extraBody` 的某个值、mcp 参数、worker `env` 值,或带前缀(`key: …`)/ 换行后贴;这些以前都被放行并落进持久层。
43
+ - **谁受伤**:新写入不会有人受伤(只收紧)。**存量里已经带着这种串的配置**,读写都会变:下一次写回被拒;读侧同一只判官也拒 —— 可降级域(`models` / `mcp` / `plugins` 等)该条按坏条目逐条丢并报 `entry-dropped`(同域其余条目存活),
44
+ `workers` / `entitlement` 等 gate 域沿用既有 fail-closed(整域读拒,拒句点名字段、不带值)。这是读侧的真实后果,不只是「写回被拒」—— 也是本版判 major 的原因。
45
+ - **补偿**:拒句是固定句、不回显值,zod issue 的 `path` 指到字段,运维据此找到并删掉那个值;把私钥挪到 `secretRefs` / `secretEnv`(env-NAME)并**轮换这把密钥**(它已经进过配置)。展示侧本来就把这类串整串扣成 `[redacted-secret]`,所以日志 / 告警里没有泄露面;泄露面是持久层里那份原文。没有开关可以关掉这条拒 —— 刻意不留旋钮(私钥进配置不是可调的偏好)。
46
+ **升级前先清存量**(本包没有现成的清单命令 —— CLI 只有 `migrate-workers`,所以是人工步骤):① 在要升级的配置源(本地 `config.d/*.json`、中心的域行 / 变更历史导出)里不分大小写搜 `-----BEGIN` 后接 `PRIVATE KEY`
47
+ (正则 `-----BEGIN[A-Z0-9 ]*PRIVATE KEY`,另搜百分号编码形 `PRIVATE%20KEY`);`.env` 本来就是放秘密值的地方,里面的值不经这只探测器,不在此列。② 或写几行脚本:用 `@sema-agent/settings-schema/types` 的 `containsSecretToken`
48
+ 对每份 JSON 里的每个字符串值与键各跑一遍,命中的就是存量私钥串;要把命中打印出来,过 `redactSecrets`(它把命中的串读成 `[redacted-secret]`,清单里不会再抄一份私钥)。③ 命中的值挪进 `secretEnv` / `secretRefs` 所指的 env-NAME,轮换这把密钥,再升级。
49
+
50
+ ### 用户可见变化(谁受影响 / 怎么改 / 不改会怎样)
51
+
52
+ 1. **自由文本写口的提交方(管理面 / CLI / 手写 `config.d`)**:带私钥头的串 —— 不论头在串首、前缀文字之后、`key: ` 之后还是换行之后 —— 现在都被拒;公钥 / 证书照旧过。不改会怎样:提交被拒,按拒句修。
53
+ 2. **持有存量这类串的部署**:见「谁受伤」与「补偿」。升级前先按「补偿」里的人工步骤清单,把私钥挪进 `secretEnv` 并轮换;不清就升级,读侧会丢条目 / 整域 throw。
54
+ 3. **读侧消费方(server 读 `/effective`、`loadLocalConfig`)**:对干净配置零变化。
55
+ 4. **共用 `containsSecretToken` 做展示 / 门判的其它仓**:它们对带私钥头的串现在也判阳(更严,方向一致);未在这里逐仓核对其断言 —— 提货时跑自己的套件。
56
+ 5. **持有导出面快照的测试 / 脚本**:无变化(导出面零 diff)。
57
+
58
+ ### 红先 / 门
59
+
60
+ - 先写钉、对着改前的树跑红,再实现:`test/private-key-anywhere-10.0.0.test.ts` —— 探测器本身(三形 × 全部既有头变体 × 百分号层 × 负控)、二十个写口位置各灌同一张表(中间形拒 / 串首形照旧拒 / 公钥与证书照旧放 / 不含 PEM 的长串照旧放)、
61
+ 自由文本写口的拒句不回显值、展示侧同三形读数不变、读侧后果(可降级域逐条丢、gate 域整域读拒)、单源钉、2 MB 对抗串线性钉、一格 🚧 缺口特征钉。写钉时对改前的树 96 格里 33 红;终稿 103 格(后补写口与缺口钉),把 `scanForSecret` 那一行拿掉复跑 37 红 66 绿,实装后 103 绿。
62
+ - 既有钉随之改真(都是借「探测器只认串首」这个缺口触发的格,按缺口登记规矩翻;实装后这两格先红,翻后绿):`test/disclosable-identity-6.3.0.test.ts` 的 R1 前提格(`containsSecretToken(encoded)` 由判阴翻判阳)与 R3 附的冻结 oracle
63
+ (由「与 6.2.0 逐格相同」改为「6.2.0 ∪ 私钥头任意位置」,头的正则在 oracle 里冻一份字面,不读 src 导出)。
64
+ - 一致性语料的变异律随之红了两个靶,按「活着的变异 = 语料缺口」补语料而不是删靶:展示侧脱敏器的「归一化没走完 ⇒ 整串扣」与「私钥头判在原文的归一化形上」这两面墙,此前分别靠
65
+ c43 的五层编码私钥头与 c41 / c43 的编码私钥头压着 —— 探测器认任意位置之后,那几条被探测器那一遍先扣下,两面墙失去杀手。新增 `c69-deep-encoded-token-past-every-reading`(九层编码的 `sk-` 形令牌)与
66
+ `c70-rewritten-token-in-front-of-encoded-pem-header`(`sk-note:` 紧贴编码私钥头、私钥体在换行之后),加冕脚本只加冕这两条;既有金样(`expected.json`)一字未动,只有 c43 的 `meta.json` 里一句已不成立的现在时说明(「五层那条只有『没走完』这一位扣得住」)订正。语料版本戳随包版本。
67
+ - 导出面登记表(`docs/API-SURFACE.md`)重生成:零 diff。
68
+
69
+ ### 未闭环(如实)
70
+
71
+ - **写与显对私钥头的读法宽度仍不一致 —— 这是缺陷,不是立场**:`containsSecretToken`(写侧)只读原文 + 前三层宽容百分号解码;**四层及更深**的编码、`+` 代空格(`-----BEGIN+PRIVATE+KEY-----`,form-urlencoded 读法)的头,
72
+ 写侧今天判阴(放行),展示侧整串扣。本版只把「头位置」这一维收到任意位置,没动读法宽度;同一个问题两只读法(`percentLayers` 与 `secretScanForms`)作答,收口方向是并成一只读法(写侧读展示侧那一组形),
73
+ 那是另一件设计,不在这次改里临时加探测臂。`test/private-key-anywhere-10.0.0.test.ts` 有一格 🚧 特征钉登记它,翻转条件写在钉里。这条缺口对其它令牌形(`sk-` 等)同样存在、早已如此,并非本版引入。
74
+ - **`SECRET_LITERAL_RE` 仍带串首锚的私钥头一支**:它是导出面,下游(例如按它的源串生成匹配表的展示层)据此判串首形;`scanForSecret` 里新加的任意位置判先于它,这一支在探测器内部已是冗余,但改它是导出正则的变更。
75
+ 直接用 `SECRET_LITERAL_RE` 整串判定的消费方,串中的私钥头仍看不见 —— 它们应改用 `containsSecretToken`。
76
+ - **存量数据没有迁移器**:升级不动持久层里已有的串;处置是运维删值 + 轮换密钥(见补偿)。本版不提供自动清洗(自动改写配置里的值不是契约层的事)。
77
+
78
+ ## 9.0.0 — 2026-10-09 — 能力读数三态读器 · 世代账两键由串改数(BREAKING)
79
+
80
+ > **判档 = major**(README「发版纪律」:导出删除 / 改名、类型 retype ⇒ major)。**硬 breaking:删改直接做,零别名,消费方 tsc 红即通知。** 两件:
81
+ > ① `readCenterCapabilities` 删,换 `readCapabilityReading` + `CapabilityReading`;② `EngineSignal.configTargetVersion` / `configAppliedVersion` 的类型由 `string` 改 `number`。
82
+ > 导出面登记表:`.` 与 `./fleet` 各 **−1**(`readCenterCapabilities`)**+2**(`readCapabilityReading` 值、`CapabilityReading` 类型),无改形。已知的消费方只有引擎侧(server)。
83
+
84
+ ### 一、能力读数三态读器:`readCapabilityReading(reply): CapabilityReading`(替掉 `readCenterCapabilities`)
85
+
86
+ **病**:`capabilities` 键在场但形坏(例:`{ servingLease: "true" }`、`null`),`readCenterCapabilities` 回 `undefined`;而 `undefined` 在引擎侧的读法是「中心没宣告」⇒ 引擎撤围栏。
87
+ 围栏这条轴上,「形坏」不许等于「缺席」—— 一份读不出的宣告可能是中心正在宣告租约,引擎却据此放掉了围栏。
88
+
89
+ **改**:读器吃心跳 **2xx 的整只答复体**(键查找进读器 —— 一只载体一只读器;不再让调用方自己取 `reply.capabilities`),答闭集三态:
90
+
91
+ - `{ kind: "declared"; capabilities: CenterCapabilities }` —— 键在场且过 `CenterCapabilities` schema(未知位被剥,缺席的位保持缺席;`{}` 也是 declared:宣告了、没有这一位);
92
+ - `{ kind: "undeclared" }` —— 体是对象、键缺席 = 老中心,什么都没说(键的值是 `undefined` 这种 JSON 载不了的形,读成键缺席);
93
+ - `{ kind: "unreadable"; why: "not-an-object" | "invalid-capabilities" }` —— 体不是对象(`null` / 数组 / 串 / 数 / 布尔 / `undefined`),或键在场但 schema 拒(含键上的 accessor 抛)。
94
+
95
+ **引擎侧契约(写进读器头注)**:只有 `declared` / `undeclared` 是读数;**`unreadable` 不是读数** —— 引擎当作没有这一拍答复:保留上一次状态(没有过读数前 = 无租约),
96
+ 租约到期钟与不可达容忍钟照旧走,不因它启动、停止或延长任何东西。同步改真的注:`LEASE_STATES` 的 `none` 句(不再把「读不出」并进没宣告)、`CenterCapabilities` 头注(「读不出」不再算缺席)、
97
+ `HEARTBEAT_REPLY_CAPABILITIES_KEY` 头注(「没有键 = 老中心」保留;补「键在场而形坏 ≠ 没宣告,是 unreadable」)。旧读器的头注随它删除。
98
+
99
+ 内部:`readSignalBody`(各 `read*` 共用的受保护读口)拆出同一个判决核心 `judgeSignalBody`,多答「为什么不是读数」(`not-an-object` / `invalid`);`readSignalBody` 是它的「读到或 `undefined`」面,
100
+ 其余读器一字不变。仍是**一只** `safeParse`、**一只** `catch`(8.1.0 的单点钉不动)。
101
+
102
+ ### 二、世代账两键由串改数:`EngineSignal.configTargetVersion` / `configAppliedVersion`
103
+
104
+ `z.string().min(1)` ⇒ **有限数**(与 `RestartSignal.version` 是同一个量、同一条规则,在 `fleet.ts` 里定义一次,内部常量 `ConfigVersion`,不导出)。理由:引擎 `/health` 铸的就是 number 世代账;
105
+ 8.2.0 起头注就写着「引擎写成数、本形类型为串」并留给下一个 major —— 这里兑现,头注里的「types them as strings」「opaque strings」两句随之删掉,出口方言由三条减为两条
106
+ (布尔 `draining`、就绪时 `/health` 不带 `ready`)。
107
+
108
+ 读数变化:带**数形**世代账的 `/health` 体,补上 `ready` 后 `readEngineSignal` 现在读得出(以前恒 `undefined`);带**串形**世代账的体现在读成 `undefined`(整只信号拒,不拿剩下的充数);
109
+ `NaN` / `±Infinity` 拒(JSON 本来也载不了)。`InstanceAnnouncement` 一直不收这两键(strip),未动。
110
+
111
+ ### 用户可见变化(谁受影响 / 怎么改 / 不改会怎样)
112
+
113
+ 1. **引擎侧租约客户端(server)**:调 `readCenterCapabilities` 的 ⇒ tsc 红(没有这个导出了)。迁移一句:
114
+ `readCenterCapabilities(reply.capabilities)?.servingLease` ⇒ `const r = readCapabilityReading(reply); r.kind === "declared" && r.capabilities.servingLease`;
115
+ 并对 `unreadable` 按契约不动状态与钟(旧代码里它与 `undeclared` 是同一个 `undefined`,所以以前会撤围栏)。对 `r.kind` 做 `switch` 时加 `default: never` 臂,以后加态编译会红。
116
+ 2. **读 `EngineSignal.configTargetVersion` / `configAppliedVersion` 的**:类型由 `string | undefined` 变 `number | undefined` ⇒ 把它当串用的 tsc 红;按数比较 / 展示即可(「equal ⇔ converged」不变)。
117
+ 拿 `readEngineSignal` 读带串形世代账的体的,现在得 `undefined`。
118
+ 3. **其它消费方**(管理面 / sdk / 只用 schema 与读写 helper 的):零变化 —— 据发版方核对,它们没有这两处的读点。
119
+ 4. **持有导出面快照的测试 / 脚本**:`readCenterCapabilities` 行删,`readCapabilityReading`(值)与 `CapabilityReading`(类型)两行加。
120
+
121
+ ### 红先 / 门
122
+
123
+ - 先写钉、对着改前的树跑红,再实现:`test/fleet-9.0.0.test.ts` 24 格(三态各一格 + 老中心 `{ ok: true }` ⇒ `undeclared`、`{ capabilities: { servingLease: "true" } }` ⇒ `unreadable` / `invalid-capabilities`、
124
+ `null` / `[]` / 串 ⇒ `unreadable` / `not-an-object`、`{ capabilities: { servingLease: true, extra: 1 } }` 按 `CenterCapabilities` 现行宽严(未知位被剥)⇒ `declared`;敌意 accessor / Proxy 不抛;
125
+ 穷举 `switch` 编译钉;旧读器从两个入口都删;五处注释文本钉;世代账两键:数收、串 / 空串 / 非有限 / 空值拒,类型面,金样 `/health` 体,与 `RestartSignal.version` 同判,头注两句已删)⇒ 写钉时对改前的树 23 格里 21 红 2 绿
126
+ (2 绿是不依赖新读器的反向格),随后补一格(键的值是 `undefined` 读成键缺席);实装后 24 格全绿。
127
+ - 既有钉随之改真:`test/engine-signal-8.1.0.test.ts`(世代账夹具与类型面改数;「形坏读成 `undefined`,读方等同缺席」那格翻成 `unreadable`,不再等同缺席;读器三格改用新读器)、
128
+ `test/fleet-8.2.0.test.ts`(能力位金样改读整只答复体;「补上 ready 也仍因数形的世代账读不出」那格翻成读得出)。
129
+ - 变异自证(九个,每个都使预期那几格红):无效声明读成 `undeclared`(旧病)/ 非对象读成 `undeclared` / 载体不过 schema / `ConfigVersion` 丢 `.finite()` /
130
+ `configTargetVersion` 退回串 / 读器头注删「`unreadable` 不是读数」句 / 键缺席读成 `unreadable` / 旧读器别名回来 / `EngineSignal` 头注留着「types them as strings」。全量读数见发版链(本节不复述会过期的数)。
131
+ - 导出面登记表(`docs/API-SURFACE.md`)重生成:`.` 与 `./fleet` 各 −1 +2。
132
+
133
+ ### 未闭环(如实)
134
+
135
+ - **引擎对 `unreadable` 的处置是契约不是本包能判的**:「保留上一次状态与钟」写在读器头注里,本包没有取引擎源码的通道,没有机器门看引擎是否照做。
136
+ - **`EngineSignal` 仍未被任何出口整只承载**:心跳的布尔 `draining`、`/health` 就绪时不带 `ready`,以及 `isReadyForTraffic` / `isRoutable` 仍吃布尔方言 —— 头注与 8.2.0 起都写「下一个 major」收;
137
+ 本版只兑现了世代账两键,这几件仍待下一个 major。`InstanceHealth` 的 `restart` 仍未入形(同样留给下一个 major)。
138
+ - **读器不判中心与引擎的凭证对应**:`declared` 里的位按调用方凭证答(共享令牌看不到 `servingLease`)是中心的事,本读器只读答复体里有什么。
139
+
9
140
  ## 8.3.1 — 2026-10-08 — FLEET_SCOPE_ENV 注改真(patch)
10
141
 
11
142
  > **判档 = patch**(README「发版纪律」:文案 / 注释 = patch)。只改 `src/fleet.ts` 里 `FLEET_SCOPE_ENV` 的头注(它随 `.d.ts` 发出);常量值不变,导出面登记表零 diff,零运行期行为,
package/README.md CHANGED
@@ -48,6 +48,28 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
48
48
  > a zod-3 host (today the config-center product) still dedups to one instance. Moving this package to zod 4 is a major
49
49
  > of its own (S-777); 6.2.0 does not touch the dependency or peer range.
50
50
 
51
+ ## 升级指引:9.0.0 → 10.0.0(BREAKING:写入校验认串中任意位置的私钥 PEM 头;读侧同套 schema ⇒ 存量私钥串的条目被丢 / 域 throw;导出面零变化)
52
+
53
+ 自由文本写口(`extraBody` / `compat` / `headers`、URL、mcp stdio 参数、插件源、ssh 登录、worker env、车道坐标、覆盖值)此前只在私钥 PEM 头位于**串首**时拒;`"x -----BEGIN PRIVATE KEY-----"` / `"key: -----BEGIN RSA PRIVATE KEY-----"` / 换行之后的头都放行,而展示侧 `redactSecrets` 早就认串中任意位置。10.0.0 起 `containsSecretToken` 在原文与前三层百分号解码上都认串中任意位置的头(与展示侧读同一只 `PRIVATE_KEY_PEM_HEADER`),头的**位置**写与显同判;公钥 / 证书头照旧不拒,其余令牌形不变。⚠️ 读法的**宽度**写侧仍窄于展示侧:四层及更深的百分号编码、`+` 代空格的头,写侧今天仍放行(展示侧整串扣),见 CHANGELOG 10.0.0「未闭环」。**代码动作:无**(导出面零变化)。**存量配置里若已有这种串 —— 这就是本版判 major 的原因**:下一次写回被拒(拒句固定、不回显值,`path` 指到字段);读侧同一只判官也拒 ——
54
+ `models` / `mcp` / `plugins` 等可降级域该条按坏条目逐条丢并报 `entry-dropped`(同域其余条目存活),`workers` / `entitlement` 等 gate 域沿用既有的 fail-closed(整域读拒)。
55
+ **升级前先清存量**(本包没有现成的清单命令,人工步骤):不分大小写搜配置源里的 `-----BEGIN[A-Z0-9 ]*PRIVATE KEY`(另搜 `PRIVATE%20KEY`),或用 `@sema-agent/settings-schema/types` 的 `containsSecretToken` 对每份 JSON 的字符串值与键各跑一遍(打印命中时过 `redactSecrets`,清单里不再抄一份私钥);
56
+ 命中的值挪进 `secretEnv` / `secretRefs`(env-NAME)并轮换这把密钥(它已经进过配置)。逐条见 CHANGELOG 10.0.0。
57
+
58
+ ## 升级指引:8.3.1 → 9.0.0(BREAKING:两件,硬 breaking —— 删改直接做,零别名)
59
+
60
+ **① 能力读数改三态读器**:`readCenterCapabilities(body)` 删,换 `readCapabilityReading(reply): CapabilityReading`(类型 `CapabilityReading` 同入)。输入改成心跳 **2xx 的整只答复体**(键查找进读器),
61
+ 答三态:`{ kind: "declared"; capabilities }`(`capabilities` 键在场且过 schema)· `{ kind: "undeclared" }`(体是对象、键缺席 = 老中心)·
62
+ `{ kind: "unreadable"; why: "not-an-object" | "invalid-capabilities" }`(体不是对象 / 键在场但 schema 拒)。**只有 `declared` / `undeclared` 是读数;`unreadable` 不是读数 —— 引擎保留上一次状态与钟。**
63
+
64
+ - **谁需要**:引擎侧的租约客户端(server)—— 围栏这条轴上「形坏」不许等于「缺席」:以前 `capabilities` 键在场但形坏读成 `undefined` = 「没宣告」,引擎据此撤围栏。
65
+ - **谁受伤**:调 `readCenterCapabilities` 的(tsc 红:没有这个导出了)。其它消费方(管理面 / sdk)据发版方核对没有读点。
66
+ - **迁移一句**:`readCenterCapabilities(reply.capabilities)?.servingLease` ⇒ `const r = readCapabilityReading(reply); r.kind === "declared" && r.capabilities.servingLease`;
67
+ 并对 `unreadable` 按契约不动状态与钟(旧代码里它和 `undeclared` 是同一个 `undefined`)。
68
+
69
+ **② `EngineSignal.configTargetVersion` / `configAppliedVersion` 由串改数**(有限数,与 `RestartSignal.version` 同一条规则):引擎 `/health` 铸的就是 number 世代账。
70
+ - **谁受伤**:把这两键当 `string` 用的(tsc 红);拿 `readEngineSignal` 读带串形世代账的体的(现在读成 `undefined`)。反过来,以前因数形世代账而读不出的 `/health` 体(补上 `ready` 后)现在读得出。
71
+ - **补偿**:按数用即可,没有别的动作。逐条见 CHANGELOG 9.0.0。
72
+
51
73
  ## 升级指引:8.3.0 → 8.3.1(注释改真,零动作可升)
52
74
 
53
75
  `FLEET_SCOPE_ENV` 的头注改真:引擎读这只 env 只用来比对 `DrainRequest.scope`;续租体与心跳的 scope 由凭证定、不取这只 env。导出面与行为零变化,逐条见 CHANGELOG 8.3.1。
@@ -91,12 +113,12 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
91
113
  - `RestartSignal.withheld?` —— 可选布尔,引擎自判「这一拍不该被换代」;缺席 = `false`,读出形不铸键。
92
114
  - `EngineSignal`(+ `EngineDraining`)与 `readEngineSignal(health)` —— 引擎自铸信号的**闭形**(`ready` 必填,`draining?` / `restart?` / `generation?` / `instanceId?` /
93
115
  `leaseUntil?` / `configTargetVersion?` / `configAppliedVersion?` 可选);与既有读器走同一只受保护读口。⚠️ 8.2.0 订正:今天的心跳与 `/health` 都不整只
94
- 承载它(心跳恒带布尔 `draining`、`/health` 就绪时不带 `ready`、世代账两键是数),`readEngineSignal` 对今天的心跳体恒 `undefined` —— 见 8.2.0 升级指引。
116
+ 承载它(心跳恒带布尔 `draining`、`/health` 就绪时不带 `ready`、世代账两键是数),`readEngineSignal` 对今天的心跳体恒 `undefined` —— 见 8.2.0 升级指引(9.0.0 起这两键在 `EngineSignal` 里就是数,不再是读不出的原因)。
95
117
  `undefined` = 没有这只信号,**不是就绪**;布尔 `draining` 的旧方言体读不出它(两个就绪谓词的输入仍是布尔方言,下一个 major 收)。
96
118
  - 代际 —— `generation` 是非负整数的**规范十进制串**(`"7"`,不是 `"07"`、不是数字型),判别器 `isCanonicalGeneration`、数值比较 `compareGeneration`;三处 wire 形共用这一条判据。
97
119
  - 服务租约 API 型 —— `ServingLeaseRequest`、`ServingLeaseResponse`(`granted { ttlMs }` / `denied { reason: superseded | retired }` / `unknown` 三词闭集,
98
120
  **`unknown` 不是 `denied` 的一种**,`denied` 只凭正面证据;读器按 `verdict` 键判,`denied` 的原因词读不出时仍是 `denied`,原因读成 `unknown_reason`)、`SERVING_LEASE_TTL_DEFAULT_MS` / `SERVING_LEASE_TTL_FLOOR_MS` / `SERVING_LEASE_DRIFT_MARGIN_MS`、读器 `readServingLeaseRequest` / `readServingLeaseResponse`;
99
- 中心能力位 `CenterCapabilities { servingLease?: true }` + `readCenterCapabilities` —— 租约客户端只在位在场时启用,缺席 = 中心不支持租约(不续租、不围栏)。
121
+ 中心能力位 `CenterCapabilities { servingLease?: true }` + `readCenterCapabilities`(9.0.0 起删,由 `readCapabilityReading` 取代,见 9.0.0 升级指引)—— 租约客户端只在位在场时启用,缺席 = 中心不支持租约(不续租、不围栏)。
100
122
  - 排空口型 —— `DrainRequest`、`DrainReceipt`(`state: draining | already_draining`)、读器 `readDrainRequest` / `readDrainReceipt`。
101
123
  - `FLEET_GENERATION_ENV` —— 配置渲染器与引擎共用的 generation env 名常量(实例身份 `instanceId` 由引擎每进程自铸,没有 env 名);它同时进了 `RESERVED_WORKER_ENV`,`worker.env` / `secretEnv` 设不了它。
102
124
  - `clampServingLeaseTtlMs(n)` —— 配置的租约 TTL 的唯一夹取点(读不出 ⇒ 缺省 600 000;低于地板 ⇒ 60 000;无上限)。
@@ -354,6 +376,8 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
354
376
  the center's capability bits: `HEARTBEAT_REPLY_CAPABILITIES_KEY` (`"capabilities"`) on the heartbeat's 2xx reply.
355
377
  **8.3.0** (additive) adds the env name `FLEET_SCOPE_ENV` (`"FLEET_SCOPE"`) — the tenant scope a fleet member belongs to, set by the
356
378
  config renderer and read by the engine as its own identity's scope (absent ⇒ `GLOBAL_SCOPE`); a reserved worker env name.
379
+ **9.0.0** (BREAKING) replaces `readCenterCapabilities` with the three-state reader `readCapabilityReading(reply)` (`declared` |
380
+ `undeclared` | `unreadable` — a malformed declaration is not an absent one) and types `EngineSignal`'s two config-version keys as numbers.
357
381
  - **Declared-but-unvalidated keys** (`PARSE_TRANSPARENT_KEYS`, 2.0.0): the ONE registry of keys this package
358
382
  carries verbatim WITHOUT checking their shape, each row naming who does check it (the engine's ingest side or
359
383
  a consumer's enforcement point). Today: `models[].compat` (1.10.0), `models[].headers` (6.4.0; the header-name
@@ -1 +1 @@
1
- 8.3.1
1
+ 10.0.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "contributor": "contract",
3
- "claim": "6.3.0(codex gpt-6-astra r1 F1 部分修,S-792):归一化那一处的两件事压在语料上。① `+` 代空格:扁平无换行的 `-----BEGIN+PRIVATE+KEY-----<base64>-----END+PRIVATE+KEY-----` 全在身份白名单字符集内,6.2.0 起它过身份与 refusal 两谓词 ⇒ 被丢条目的 id 必须不带(`secretScanForms` 把 `+` 读作空格的形交 PEM 头判,身份否决随之);URLSearchParams 形(`+` 代空格 + `%0A`)作 roles 坏键 ⇒ 坐标段整段扣、error 不带私钥。② 解码界上 fail-closed:四层 / 五层百分号编码作 roles 坏键 ⇒ 三层走完仍能再解,整串扣(codex r2 起展示侧的边界读法对末层再解一层,四层那条因此也被 PEM 头判扣下;五层那条只有「没走完」这一位扣得住 —— 变异门的 `unresolved` 靶靠它)。正控:`o3-mini+high` 作 id 逐字带出(`+` 在真实 id 里照旧可披露)。",
3
+ "claim": "6.3.0(codex gpt-6-astra r1 F1 部分修,S-792):归一化那一处的两件事压在语料上。① `+` 代空格:扁平无换行的 `-----BEGIN+PRIVATE+KEY-----<base64>-----END+PRIVATE+KEY-----` 全在身份白名单字符集内,6.2.0 起它过身份与 refusal 两谓词 ⇒ 被丢条目的 id 必须不带(`secretScanForms` 把 `+` 读作空格的形交 PEM 头判,身份否决随之);URLSearchParams 形(`+` 代空格 + `%0A`)作 roles 坏键 ⇒ 坐标段整段扣、error 不带私钥。② 解码界上 fail-closed:四层 / 五层百分号编码作 roles 坏键 ⇒ 三层走完仍能再解,整串扣(codex r2 起展示侧的边界读法对末层再解一层,四层那条因此也被 PEM 头判扣下;五层那条只有「没走完」这一位扣得住 —— 变异门的 `unresolved` 靶靠它)。正控:`o3-mini+high` 作 id 逐字带出(`+` 在真实 id 里照旧可披露)。 【10.0.0 订正】探测器改认串中任意位置的私钥头之后,它对每个归一化形再读三层,五层那条也被探测器那一遍扣下,「没走完」这一位不再独自承重于本条;`unresolved` 靶的语料杀手改为 c69。",
4
4
  "firstRedOn": [],
5
5
  "secrets": [
6
6
  "MC4CAQAwBQYDK2VwBCIEIFAKEC43FLATPLUSIDBODY0000000000000000000000",
@@ -8,5 +8,5 @@
8
8
  "MC4CAQAwBQYDK2VwBCIEIFAKEC43DEEPENCODEDROLEKEYBODY00000000000000",
9
9
  "MC4CAQAwBQYDK2VwBCIEIFAKEC43FIVELAYERROLEKEYBODY0000000000000000"
10
10
  ],
11
- "note": "车PE(2026-09-27)红先:加本条时 6.3.0 修前(A 已修、B 未修)读腿在三腿上报告警差 + 秘密扫描命中。变异门为 `secretScanForms` 的 `+` 读法与 `redactSecrets` 的 `unresolved` 一位各登记一靶,本条杀。firstRedOn 为空:historical 阶梯证据真跑于本条入库之前,那一栏只收 historical 真跑。 codex r2 收敛(车PE 同日):边界读法上线后四层那条不再单靠 `unresolved`,追加五层一条(`FAKEC43FIVELAYER…`)使该靶仍有语料杀。"
11
+ "note": "车PE(2026-09-27)红先:加本条时 6.3.0 修前(A 已修、B 未修)读腿在三腿上报告警差 + 秘密扫描命中。变异门为 `secretScanForms` 的 `+` 读法与 `redactSecrets` 的 `unresolved` 一位各登记一靶,本条杀。firstRedOn 为空:historical 阶梯证据真跑于本条入库之前,那一栏只收 historical 真跑。 codex r2 收敛(车PE 同日):边界读法上线后四层那条不再单靠 `unresolved`,追加五层一条(`FAKEC43FIVELAYER…`)使该靶仍有语料杀。 【10.0.0】`unresolved` 靶改由 c69 杀,本条对该靶不再红。"
12
12
  }
@@ -0,0 +1,103 @@
1
+ {
2
+ "domains": {
3
+ "outcome": "effective",
4
+ "effective": {
5
+ "collab": {
6
+ "templates": []
7
+ },
8
+ "entitlement": {
9
+ "budgetGroups": [],
10
+ "groupBindings": [],
11
+ "subjects": [],
12
+ "tiers": []
13
+ },
14
+ "execution": {
15
+ "allowedLanes": [
16
+ "host"
17
+ ],
18
+ "required": false
19
+ },
20
+ "governance": {},
21
+ "hosts": {
22
+ "hosts": []
23
+ },
24
+ "limits": {},
25
+ "mcp": {
26
+ "servers": []
27
+ },
28
+ "models": {
29
+ "models": [
30
+ {
31
+ "api": "openai-completions",
32
+ "apiKeyEnv": "OPENAI_API_KEY",
33
+ "enabled": true,
34
+ "id": "keep-id",
35
+ "name": "keep",
36
+ "provider": "openai",
37
+ "quotaWeight": 1,
38
+ "reasoning": false,
39
+ "tier": "standard",
40
+ "vision": false
41
+ }
42
+ ],
43
+ "roles": {
44
+ "summarize": {
45
+ "model": "keep"
46
+ }
47
+ },
48
+ "tierGroups": []
49
+ },
50
+ "plugins": {
51
+ "plugins": []
52
+ },
53
+ "projects": {
54
+ "projects": {}
55
+ },
56
+ "prompts": {
57
+ "bindings": []
58
+ },
59
+ "rosters": {
60
+ "rosters": []
61
+ },
62
+ "runtime": {},
63
+ "scenarios": {
64
+ "scenarios": []
65
+ },
66
+ "skills": {
67
+ "skills": []
68
+ },
69
+ "systems": {
70
+ "systems": []
71
+ },
72
+ "workers": {
73
+ "workers": []
74
+ }
75
+ },
76
+ "warnings": [
77
+ {
78
+ "domain": "models",
79
+ "kind": "entry-dropped",
80
+ "path": [
81
+ "roles",
82
+ "[redacted-secret]"
83
+ ]
84
+ }
85
+ ],
86
+ "metadata": {
87
+ "version": 15,
88
+ "updatedAt": "2026-10-09T00:00:00.000Z"
89
+ }
90
+ },
91
+ "strictWrite": {
92
+ "outcome": "rejected",
93
+ "stage": "validate",
94
+ "why": "拒:roles 里的坏键读腿丢(entry-dropped,坐标整段扣);严写 ⇒ 整次不受理。"
95
+ },
96
+ "wire": {
97
+ "derive": true
98
+ },
99
+ "localRoot": {
100
+ "derive": true,
101
+ "localVersion": 2824083313848700
102
+ }
103
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "meta": {
3
+ "version": 15,
4
+ "updatedAt": "2026-10-09T00:00:00.000Z"
5
+ },
6
+ "domains": {
7
+ "models": {
8
+ "models": [
9
+ {
10
+ "name": "keep",
11
+ "provider": "openai",
12
+ "id": "keep-id",
13
+ "apiKeyEnv": "OPENAI_API_KEY"
14
+ }
15
+ ],
16
+ "roles": {
17
+ "summarize": {
18
+ "model": "keep"
19
+ },
20
+ "key=%252525252525252573k-live-FAKEC69NINELAYERROLEKEYTOKEN0000000000": {
21
+ "model": "keep"
22
+ }
23
+ }
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "contributor": "contract",
3
+ "claim": "10.0.0:展示侧脱敏器的「归一化没走完 ⇒ 整串扣」这一位压在语料上。`sk-` 形令牌的首字母 `s` 被九重百分号编码(`%2525…2573`)作 roles 坏键:探测器的三层解码、边界读法再解一层、探测器对每个形再读三层都够不着它 ⇒ 令牌不被任何定点遍认出,只有「还能再解」这一位把整段坐标扣成 `[redacted-secret]`。此前这一位靠 c43 的五层私钥头压着;10.0.0 起探测器认串中任意位置的私钥头,五层那条也被探测器那一遍扣下,这一位随之没了语料杀手 —— 本条补上。",
4
+ "firstRedOn": [],
5
+ "secrets": [
6
+ "k-live-FAKEC69NINELAYERROLEKEYTOKEN0000000000"
7
+ ],
8
+ "note": "变异门靶:`redactSecrets` 去 `scan.unresolved || ` —— 本条杀。不是红先复现:这一位的行为 9.0.0 与 10.0.0 相同(坐标整段扣),本条只是让它在语料里再有一个承重的杀手。firstRedOn 为空:不是 historical 阶梯证据。"
9
+ }
@@ -0,0 +1,103 @@
1
+ {
2
+ "domains": {
3
+ "outcome": "effective",
4
+ "effective": {
5
+ "collab": {
6
+ "templates": []
7
+ },
8
+ "entitlement": {
9
+ "budgetGroups": [],
10
+ "groupBindings": [],
11
+ "subjects": [],
12
+ "tiers": []
13
+ },
14
+ "execution": {
15
+ "allowedLanes": [
16
+ "host"
17
+ ],
18
+ "required": false
19
+ },
20
+ "governance": {},
21
+ "hosts": {
22
+ "hosts": []
23
+ },
24
+ "limits": {},
25
+ "mcp": {
26
+ "servers": []
27
+ },
28
+ "models": {
29
+ "models": [
30
+ {
31
+ "api": "openai-completions",
32
+ "apiKeyEnv": "OPENAI_API_KEY",
33
+ "enabled": true,
34
+ "id": "keep-id",
35
+ "name": "keep",
36
+ "provider": "openai",
37
+ "quotaWeight": 1,
38
+ "reasoning": false,
39
+ "tier": "standard",
40
+ "vision": false
41
+ }
42
+ ],
43
+ "roles": {
44
+ "summarize": {
45
+ "model": "keep"
46
+ }
47
+ },
48
+ "tierGroups": []
49
+ },
50
+ "plugins": {
51
+ "plugins": []
52
+ },
53
+ "projects": {
54
+ "projects": {}
55
+ },
56
+ "prompts": {
57
+ "bindings": []
58
+ },
59
+ "rosters": {
60
+ "rosters": []
61
+ },
62
+ "runtime": {},
63
+ "scenarios": {
64
+ "scenarios": []
65
+ },
66
+ "skills": {
67
+ "skills": []
68
+ },
69
+ "systems": {
70
+ "systems": []
71
+ },
72
+ "workers": {
73
+ "workers": []
74
+ }
75
+ },
76
+ "warnings": [
77
+ {
78
+ "domain": "models",
79
+ "kind": "entry-dropped",
80
+ "path": [
81
+ "roles",
82
+ "[redacted-secret]"
83
+ ]
84
+ }
85
+ ],
86
+ "metadata": {
87
+ "version": 15,
88
+ "updatedAt": "2026-10-09T00:00:00.000Z"
89
+ }
90
+ },
91
+ "strictWrite": {
92
+ "outcome": "rejected",
93
+ "stage": "validate",
94
+ "why": "拒:roles 里的坏键读腿丢(entry-dropped,坐标整段扣);严写 ⇒ 整次不受理。"
95
+ },
96
+ "wire": {
97
+ "derive": true
98
+ },
99
+ "localRoot": {
100
+ "derive": true,
101
+ "localVersion": 1452540247706034
102
+ }
103
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "meta": {
3
+ "version": 15,
4
+ "updatedAt": "2026-10-09T00:00:00.000Z"
5
+ },
6
+ "domains": {
7
+ "models": {
8
+ "models": [
9
+ {
10
+ "name": "keep",
11
+ "provider": "openai",
12
+ "id": "keep-id",
13
+ "apiKeyEnv": "OPENAI_API_KEY"
14
+ }
15
+ ],
16
+ "roles": {
17
+ "summarize": {
18
+ "model": "keep"
19
+ },
20
+ "sk-note:-----BEGIN%20PRIVATE%20KEY-----\nMC4CAQAwBQYDK2VwBCIEIFAKEC70REWRITTENHEADERBODY00000000000000\n-----END%20PRIVATE%20KEY-----": {
21
+ "model": "keep"
22
+ }
23
+ }
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "contributor": "contract",
3
+ "claim": "10.0.0:展示侧脱敏器「私钥头判在令牌替换之前、且判在归一化形上」这两件同时压在语料上。roles 坏键 = `sk-note:` 紧贴一个 `%20` 编码的私钥头,私钥体在真实换行之后(令牌字符集之外):定点遍先把 `sk-note:-----BEGIN%20PRIVATE%20KEY-----` 整个令牌换成 `[redacted-secret]`,头就没了,换行后的体与 END 行原样留着;只有对**原文的归一化形**(`%20` 解成空格)判私钥头,才会整串扣。此前这一位靠 c41 / c43 的编码私钥头压着,10.0.0 起探测器认串中任意位置的私钥头,那两条被探测器那一遍扣下,这一位随之没了语料杀手 —— 本条补上。",
4
+ "firstRedOn": [],
5
+ "secrets": [
6
+ "MC4CAQAwBQYDK2VwBCIEIFAKEC70REWRITTENHEADERBODY00000000000000"
7
+ ],
8
+ "note": "变异门靶:`redactSecrets` 把原文形上的私钥头判退回只测原文(`PRIVATE_KEY_PEM_HEADER.test(text)`)—— 本条杀。不是红先复现:坐标整段扣这一行为 9.0.0 与 10.0.0 相同。firstRedOn 为空:不是 historical 阶梯证据。"
9
+ }
@@ -34,11 +34,14 @@ import { type DomainConfig, type DomainName, type EffectiveConfig, type Effectiv
34
34
  * The PEM header is judged BEFORE any replacement: the token pass can rewrite the header itself away
35
35
  * (`sk-note:-----BEGIN` is one token-charset run that starts with `sk-`, so it becomes `[redacted-secret]` and the
36
36
  * key body after it would ride out untouched — codex verification round). A PEM header carries spaces, so the
37
- * targeted passes can never isolate it, and the detector only sees it at the very start of a string. Every
38
- * free-text warning face that goes through here (coordinates, error text) inherits the same guarantee; the entry
39
- * identity face additionally has to be an identifier ({@link isDisclosableIdentity}). The validation-side detector
40
- * `containsSecretToken` is deliberately NOT widened here: making it reject a mid-string PEM would tighten every field
41
- * it validates (a major). */
37
+ * targeted passes can never isolate it. Every free-text warning face that goes through here (coordinates, error text)
38
+ * inherits the same guarantee; the entry identity face additionally has to be an identifier ({@link isDisclosableIdentity}).
39
+ * Write and display read the private-key header at the same PLACE and with the same regex since 10.0.0: the validation-side
40
+ * detector `containsSecretToken` reads {@link PRIVATE_KEY_PEM_HEADER} anywhere in a string too, on the raw text and its first three
41
+ * percent layers. They do NOT yet read the same FORMS: this redactor reads more (deeper layers, the `+` and boundary readings of
42
+ * `secretScanForms`), so a header spelled only in one of those forms is withheld on display but still passes write validation —
43
+ * an open gap (a pin in the 10.0.0 test file registers it), not a contract. The redactor also keeps its own PEM check on the ORIGINAL
44
+ * text's forms because the token pass above can rewrite the header away before `containsSecretToken` ever sees the result. */
42
45
  export declare function redactSecrets(text: string): string;
43
46
  /** Sanitize a ZodError IN PLACE so no surfaced error (message / `received` / nested `unionErrors`) echoes a
44
47
  * pasted secret. RECURSES into `unionErrors` (a `z.union` failure nests a ZodError per member — e.g. RoleTarget
@@ -38,11 +38,14 @@ import { DOMAIN_SCHEMAS, GOVERNANCE_MIRROR_KEYS, HOSTS_ARRAY_MAX, HOST_NAME_MAX,
38
38
  * The PEM header is judged BEFORE any replacement: the token pass can rewrite the header itself away
39
39
  * (`sk-note:-----BEGIN` is one token-charset run that starts with `sk-`, so it becomes `[redacted-secret]` and the
40
40
  * key body after it would ride out untouched — codex verification round). A PEM header carries spaces, so the
41
- * targeted passes can never isolate it, and the detector only sees it at the very start of a string. Every
42
- * free-text warning face that goes through here (coordinates, error text) inherits the same guarantee; the entry
43
- * identity face additionally has to be an identifier ({@link isDisclosableIdentity}). The validation-side detector
44
- * `containsSecretToken` is deliberately NOT widened here: making it reject a mid-string PEM would tighten every field
45
- * it validates (a major). */
41
+ * targeted passes can never isolate it. Every free-text warning face that goes through here (coordinates, error text)
42
+ * inherits the same guarantee; the entry identity face additionally has to be an identifier ({@link isDisclosableIdentity}).
43
+ * Write and display read the private-key header at the same PLACE and with the same regex since 10.0.0: the validation-side
44
+ * detector `containsSecretToken` reads {@link PRIVATE_KEY_PEM_HEADER} anywhere in a string too, on the raw text and its first three
45
+ * percent layers. They do NOT yet read the same FORMS: this redactor reads more (deeper layers, the `+` and boundary readings of
46
+ * `secretScanForms`), so a header spelled only in one of those forms is withheld on display but still passes write validation —
47
+ * an open gap (a pin in the 10.0.0 test file registers it), not a contract. The redactor also keeps its own PEM check on the ORIGINAL
48
+ * text's forms because the token pass above can rewrite the header away before `containsSecretToken` ever sees the result. */
46
49
  export function redactSecrets(text) {
47
50
  const targeted = text
48
51
  .replace(/\b(bearer|basic)(\s+)\S+/gi, "$1$2[redacted-secret]")
package/dist/fleet.d.ts CHANGED
@@ -90,8 +90,10 @@ export declare function compareGeneration(a: string, b: string): -1 | 0 | 1;
90
90
  /**
91
91
  * The serving lease as the ENGINE holds it — a CLOSED set of four words, owned here (the engine's vocabulary, word for word;
92
92
  * a reader that switches on it exhaustively is told by the compiler when a word is added):
93
- * - `none` — the lease is not in effect: the center has not announced {@link CenterCapabilities}`.servingLease` (an older or
94
- * degraded center). The engine behaves exactly as an engine without a lease — no renewals, no fence.
93
+ * - `none` — the lease is not in effect: the center has declared no {@link CenterCapabilities}`.servingLease` ({@link readCapabilityReading}
94
+ * says `undeclared` — an older center — or `declared` without the bit). The engine behaves exactly as an engine without a lease —
95
+ * no renewals, no fence. A reply that cannot be read (`unreadable`) is not a reading: it does not move the engine into or out of
96
+ * `none` — it keeps the state it had.
95
97
  * - `granted` — the engine holds a lease; `leaseUntil` says until when (epoch ms, the engine's own computation from the
96
98
  * granted `ttlMs`, see {@link ServingLeaseResponse}).
97
99
  * - `expired` — the lease is in effect but no grant is valid right now (the center is unreachable, answered `unknown`, or has
@@ -118,7 +120,8 @@ export type LeaseState = z.infer<typeof LeaseState>;
118
120
  * the contract only guarantees non-empty words, at least one).
119
121
  * - `version` — the config-center version that currently differs from boot (the LATEST seen — it moves forward with
120
122
  * later publishes, so a "one restart per version" dedupe bounds restarts per config version, not per reason set).
121
- * Judged exactly as `readEffectiveWire` judges a config version (a finite number) — one quantity, one rule.
123
+ * Judged exactly as `readEffectiveWire` judges a config version (a finite number, `ConfigVersion`) — one quantity, one rule;
124
+ * {@link EngineSignal}'s two version-ledger keys use the same one.
122
125
  * - `since` — epoch ms when this restart first became required (stable across refreshes while `reasons` holds).
123
126
  * - `withheld` — optional: the ENGINE's own verdict that this beat must not be handed to a new generation ("a restart is
124
127
  * required, but replacing this instance right now would lose state it holds"). Absent ≡ `false` — an explicit `false`
@@ -584,13 +587,12 @@ export type EngineDraining = z.infer<typeof EngineDraining>;
584
587
  * this shape off a body whose keys sit flat on it (no wrapper key).
585
588
  *
586
589
  * Where it rides today: on NEITHER exit whole. The heartbeat ({@link InstanceAnnouncement}) and `/health` ({@link InstanceHealth})
587
- * still speak the 8.0 dialect for the keys they share with this shape, in three ways:
590
+ * still speak the 8.0 dialect for the keys they share with this shape, in two ways:
588
591
  * 1. `draining` is a BOOLEAN there, not the {@link EngineDraining} object. The heartbeat always carries it (`false` included);
589
592
  * `/health` carries `draining: true` only while draining.
590
593
  * 2. `/health` carries `ready` only while NOT ready (`ready: false`); a ready engine's `/health` body has no `ready` key.
591
- * 3. The engine writes `configTargetVersion` / `configAppliedVersion` as numbers; this shape types them as strings.
592
594
  * So `readEngineSignal` over a heartbeat body is always `undefined` (point 1), and over a `/health` body it is `undefined` whenever
593
- * any of the three applies. What does ride both exits flat, under these names and with these values: `restart`, `instanceId`
595
+ * either applies. What does ride both exits flat, under these names and with these values: `restart`, `instanceId`
594
596
  * and the fleet-membership keys `generation` / `leaseState` / `leaseUntil` — on the heartbeat, read them off the parsed
595
597
  * {@link InstanceAnnouncement} (which types all five). The next major retires the boolean pair (`draining` / `drainingSince`),
596
598
  * has both exits carry this shape whole and moves {@link isReadyForTraffic} / {@link isRoutable} onto it; until then this is
@@ -612,7 +614,7 @@ export type EngineDraining = z.infer<typeof EngineDraining>;
612
614
  * - `leaseUntil` — epoch ms until which the engine holds its serving lease, as the engine computed it from the last
613
615
  * `ttlMs` it was granted ({@link ServingLeaseResponse}); present ⇔ `leaseState` is `granted`.
614
616
  * - `configTargetVersion` / `configAppliedVersion` — the config-center version the engine was told to converge on, and the
615
- * one it has applied (opaque strings; equal ⇔ converged).
617
+ * one it has applied: numbers, as the engine mints them (finite, the same rule as {@link RestartSignal}`.version`; equal ⇔ converged).
616
618
  *
617
619
  * Reading: `EngineSignal` is parsed with the default strip — a whole /health body goes in, only these keys come out. A
618
620
  * shape violation in ANY key makes the whole signal `undefined` (it is never "the rest of it"). `undefined` means "no
@@ -655,8 +657,8 @@ export declare const EngineSignal: z.ZodObject<{
655
657
  instanceId: z.ZodOptional<z.ZodString>;
656
658
  leaseState: z.ZodOptional<z.ZodEnum<["none", "granted", "expired", "denied"]>>;
657
659
  leaseUntil: z.ZodOptional<z.ZodNumber>;
658
- configTargetVersion: z.ZodOptional<z.ZodString>;
659
- configAppliedVersion: z.ZodOptional<z.ZodString>;
660
+ configTargetVersion: z.ZodOptional<z.ZodNumber>;
661
+ configAppliedVersion: z.ZodOptional<z.ZodNumber>;
660
662
  }, "strip", z.ZodTypeAny, {
661
663
  ready: boolean;
662
664
  draining?: {
@@ -674,8 +676,8 @@ export declare const EngineSignal: z.ZodObject<{
674
676
  generation?: string | undefined;
675
677
  leaseState?: "none" | "granted" | "expired" | "denied" | undefined;
676
678
  leaseUntil?: number | undefined;
677
- configTargetVersion?: string | undefined;
678
- configAppliedVersion?: string | undefined;
679
+ configTargetVersion?: number | undefined;
680
+ configAppliedVersion?: number | undefined;
679
681
  }, {
680
682
  ready: boolean;
681
683
  draining?: {
@@ -693,8 +695,8 @@ export declare const EngineSignal: z.ZodObject<{
693
695
  generation?: string | undefined;
694
696
  leaseState?: "none" | "granted" | "expired" | "denied" | undefined;
695
697
  leaseUntil?: number | undefined;
696
- configTargetVersion?: string | undefined;
697
- configAppliedVersion?: string | undefined;
698
+ configTargetVersion?: number | undefined;
699
+ configAppliedVersion?: number | undefined;
698
700
  }>;
699
701
  export type EngineSignal = z.infer<typeof EngineSignal>;
700
702
  /**
@@ -881,13 +883,15 @@ export declare function readServingLeaseResponse(body: unknown): ServingLeaseRea
881
883
  * What the CENTER says it can do: capability bits an engine reads before it starts using a center-side endpoint.
882
884
  * - `servingLease` — the center answers serving-lease renewals ({@link ServingLeaseRequest} / {@link ServingLeaseResponse}).
883
885
  *
884
- * A bit is `true` or absent — one spelling for "not supported" (there is no `false`). Absent (an older center, a body without
885
- * the key, a body that cannot be read) means the center does not support the lease, and the engine then neither renews a
886
- * lease nor is fenced by one: this is the compatibility gate that lets an executor ship ahead of the center without pushing
887
- * running engines into lease expiry. It is a gate, not a verdict — nothing in this shape says "the center refuses leases".
888
- * A reader asks `readCenterCapabilities(body)?.servingLease === true` and starts the lease client only on that.
889
- * Unknown bits are stripped (a newer center's extra bits never break an older reader); a known bit whose value is not `true`
890
- * makes the whole read `undefined`, which reads exactly as absent.
886
+ * A bit is `true` or absent — one spelling for "not supported" (there is no `false`). An absent bit — in a declared object, or
887
+ * because the center declares nothing at all (an older center) — means the center does not support the lease, and the engine then
888
+ * neither renews a lease nor is fenced by one: this is the compatibility gate that lets an executor ship ahead of the center
889
+ * without pushing running engines into lease expiry. It is a gate, not a verdict — nothing in this shape says "the center refuses
890
+ * leases". Read a heartbeat reply with {@link readCapabilityReading} and start the lease client only on a `declared` reading whose
891
+ * `capabilities.servingLease === true`.
892
+ * Unknown bits are stripped (a newer center's extra bits never break an older reader); a known bit whose value is not `true` breaks
893
+ * the declaration — and a broken declaration is NOT an absent one: the reply reads as `unreadable`, which is not a reading (the engine
894
+ * keeps the state it had; see {@link readCapabilityReading}).
891
895
  * It rides in ONE place: the heartbeat's 2xx reply ({@link FLEET_PATHS}`.instances`), under {@link HEARTBEAT_REPLY_CAPABILITIES_KEY}.
892
896
  * The center answers the bits per CALLER CREDENTIAL: under a per-worker token, by what the center supports; under the shared token
893
897
  * `servingLease` is always absent (the serving-lease endpoint takes per-worker tokens only, so announcing it there would only push
@@ -903,15 +907,39 @@ export declare const CenterCapabilities: z.ZodObject<{
903
907
  export type CenterCapabilities = z.infer<typeof CenterCapabilities>;
904
908
  /**
905
909
  * The key of the heartbeat's 2xx reply body under which the center puts its {@link CenterCapabilities} — the object's only carrier.
906
- * The center writes `{ …, [HEARTBEAT_REPLY_CAPABILITIES_KEY]: caps }`; the engine reads
907
- * `readCenterCapabilities(reply[HEARTBEAT_REPLY_CAPABILITIES_KEY])`, and only on a 2xx (a failed heartbeat says nothing about the
908
- * bits). Nested, not flat: the reply's own keys and the capability bits never share a namespace. A reply without the key is an
909
- * older center — no capability said.
910
+ * The center writes `{ …, [HEARTBEAT_REPLY_CAPABILITIES_KEY]: caps }`; the engine reads the whole reply with
911
+ * `readCapabilityReading(reply)` — the key lookup lives in the reader, one carrier one reader — and only on a 2xx (a failed heartbeat
912
+ * says nothing about the bits). Nested, not flat: the reply's own keys and the capability bits never share a namespace.
913
+ * A reply without the key is an older center — no capability said (`undeclared`). A reply with the key present with a value that
914
+ * breaks the shape is NOT "without the key" — it is `unreadable`, and an unreadable reply says nothing: neither declared nor undeclared.
910
915
  */
911
916
  export declare const HEARTBEAT_REPLY_CAPABILITIES_KEY = "capabilities";
912
- /** Engine side: the center's capability object. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws
913
- * (and `undefined` means "no capability said" — the same as every bit absent). */
914
- export declare function readCenterCapabilities(body: unknown): CenterCapabilities | undefined;
917
+ /** What one heartbeat reply says about the center's capabilities — three readings of the reply, see {@link readCapabilityReading}. */
918
+ export type CapabilityReading = {
919
+ kind: "declared";
920
+ capabilities: CenterCapabilities;
921
+ } | {
922
+ kind: "undeclared";
923
+ } | {
924
+ kind: "unreadable";
925
+ why: "not-an-object" | "invalid-capabilities";
926
+ };
927
+ /**
928
+ * Engine side: what one heartbeat reply says about the center's capabilities. The input is the heartbeat's whole 2xx reply body
929
+ * (the key lookup lives here — one carrier, one reader), the output one of three readings:
930
+ * - `declared` — the key ({@link HEARTBEAT_REPLY_CAPABILITIES_KEY}) is present and its value passes {@link CenterCapabilities};
931
+ * `capabilities` is the parsed object (unknown bits stripped, an absent bit stays absent).
932
+ * - `undeclared` — the reply is an object and the key is absent: an older center, which has said nothing about capabilities.
933
+ * (`undefined` as the key's value is what JSON cannot carry; it reads as the key being absent.)
934
+ * - `unreadable` — the reply is not an object (`why: "not-an-object"`), or the key is present but its value is not a
935
+ * {@link CenterCapabilities} (`why: "invalid-capabilities"`; an accessor that throws reads the same way).
936
+ * Only `declared` and `undeclared` are readings. `unreadable` is not a reading: on the fence axis a malformed declaration must not
937
+ * read as "the center declared nothing", so the engine acts on it as on no reply at all — it keeps the state it last had (before
938
+ * any reading: no lease) and leaves its clocks, the lease expiry and the outage allowance, as they were. Start the lease client only
939
+ * on `declared` with `capabilities.servingLease === true`. Never throws; judged by the same one guarded body judgement as every
940
+ * other reader here.
941
+ */
942
+ export declare function readCapabilityReading(reply: unknown): CapabilityReading;
915
943
  /**
916
944
  * The drain endpoint's request: "put this instance into draining, by this deadline". One persisted action, one validation,
917
945
  * whether the center calls directly or an executor retries it. Two lanes, one shape: on the Docker lane (and for a direct
package/dist/fleet.js CHANGED
@@ -79,6 +79,9 @@ export function drainVerdict(s) {
79
79
  }
80
80
  /** A wall-clock instant as epoch milliseconds — one quantity, one rule, wherever the fleet contract carries a point in time. */
81
81
  const EpochMs = z.number().int().nonnegative();
82
+ /** A config-center version — a finite number, judged the way `readEffectiveWire` judges one. One quantity, one rule, wherever the
83
+ * fleet contract carries it: {@link RestartSignal}`.version` and {@link EngineSignal}'s version-ledger keys. */
84
+ const ConfigVersion = z.number().finite();
82
85
  /**
83
86
  * A generation is a non-negative integer written as its CANONICAL decimal string: `"0"`, `"7"`, `"10"` — no sign, no
84
87
  * leading zero (`"07"` is not a generation), no fraction, no whitespace, ASCII digits only, and a string, never a number.
@@ -110,8 +113,10 @@ const Generation = z.string().refine(isCanonicalGeneration, { message: "a genera
110
113
  /**
111
114
  * The serving lease as the ENGINE holds it — a CLOSED set of four words, owned here (the engine's vocabulary, word for word;
112
115
  * a reader that switches on it exhaustively is told by the compiler when a word is added):
113
- * - `none` — the lease is not in effect: the center has not announced {@link CenterCapabilities}`.servingLease` (an older or
114
- * degraded center). The engine behaves exactly as an engine without a lease — no renewals, no fence.
116
+ * - `none` — the lease is not in effect: the center has declared no {@link CenterCapabilities}`.servingLease` ({@link readCapabilityReading}
117
+ * says `undeclared` — an older center — or `declared` without the bit). The engine behaves exactly as an engine without a lease —
118
+ * no renewals, no fence. A reply that cannot be read (`unreadable`) is not a reading: it does not move the engine into or out of
119
+ * `none` — it keeps the state it had.
115
120
  * - `granted` — the engine holds a lease; `leaseUntil` says until when (epoch ms, the engine's own computation from the
116
121
  * granted `ttlMs`, see {@link ServingLeaseResponse}).
117
122
  * - `expired` — the lease is in effect but no grant is valid right now (the center is unreachable, answered `unknown`, or has
@@ -137,7 +142,8 @@ export const LeaseState = z.enum(LEASE_STATES);
137
142
  * the contract only guarantees non-empty words, at least one).
138
143
  * - `version` — the config-center version that currently differs from boot (the LATEST seen — it moves forward with
139
144
  * later publishes, so a "one restart per version" dedupe bounds restarts per config version, not per reason set).
140
- * Judged exactly as `readEffectiveWire` judges a config version (a finite number) — one quantity, one rule.
145
+ * Judged exactly as `readEffectiveWire` judges a config version (a finite number, `ConfigVersion`) — one quantity, one rule;
146
+ * {@link EngineSignal}'s two version-ledger keys use the same one.
141
147
  * - `since` — epoch ms when this restart first became required (stable across refreshes while `reasons` holds).
142
148
  * - `withheld` — optional: the ENGINE's own verdict that this beat must not be handed to a new generation ("a restart is
143
149
  * required, but replacing this instance right now would lose state it holds"). Absent ≡ `false` — an explicit `false`
@@ -146,29 +152,39 @@ export const LeaseState = z.enum(LEASE_STATES);
146
152
  */
147
153
  export const RestartSignal = z.object({
148
154
  reasons: z.array(z.string().min(1)).min(1),
149
- version: z.number().finite(),
155
+ version: ConfigVersion,
150
156
  since: EpochMs,
151
157
  withheld: z.boolean().optional(),
152
158
  });
153
159
  /**
154
- * The ONE guarded body reader behind every wire-shape read in this file: the engine-owned signals on /health
155
- * ({@link readRestartSignal}, {@link readModelRefusals}, {@link readEngineSignal}) and the request / reply bodies of the
156
- * fleet endpoints ({@link readServingLeaseRequest}, {@link readServingLeaseResponse}, {@link readDrainRequest},
157
- * {@link readDrainReceipt}). `pick(body)` — one key of the body, or the body itself — is judged against that shape's one
158
- * schema. Shape mismatch, a non-object body, even a throwing accessor ⇒ `undefined` — never throws (a probe must not die
159
- * on one odd body). A second hand-written guard anywhere else in this package is the defect, not a convenience.
160
+ * The ONE guarded body judgement behind every wire-shape read in this file: `pick(body)` — one key of the body, or the body itself —
161
+ * is judged against that shape's one schema, and the verdict says WHY when it is not a read: `not-an-object` (the body is null, an
162
+ * array or a scalar) or `invalid` (the shape rejected it, or an accessor threw). It never throws (a probe must not die on one odd
163
+ * body). Almost every reader wants only "a read or nothing" and goes through {@link readSignalBody}; the one reader whose
164
+ * failure direction matters ({@link readCapabilityReading}) takes the reason. A second hand-written guard anywhere else in this
165
+ * package is the defect, not a convenience.
160
166
  */
161
- function readSignalBody(body, pick, schema) {
167
+ function judgeSignalBody(body, pick, schema) {
162
168
  try {
163
169
  if (body === null || typeof body !== "object" || Array.isArray(body))
164
- return undefined;
170
+ return { kind: "not-an-object" };
165
171
  const parsed = schema.safeParse(pick(body));
166
- return parsed.success ? parsed.data : undefined;
172
+ return parsed.success ? { kind: "read", value: parsed.data } : { kind: "invalid" };
167
173
  }
168
174
  catch {
169
- return undefined;
175
+ return { kind: "invalid" };
170
176
  }
171
177
  }
178
+ /**
179
+ * The reader behind every wire-shape read in this file that has no use for a reason: the engine-owned signals on /health
180
+ * ({@link readRestartSignal}, {@link readModelRefusals}, {@link readEngineSignal}) and the request / reply bodies of the
181
+ * fleet endpoints ({@link readServingLeaseRequest}, {@link readServingLeaseResponse}, {@link readDrainRequest},
182
+ * {@link readDrainReceipt}). Shape mismatch, a non-object body, even a throwing accessor ⇒ `undefined` — never throws.
183
+ */
184
+ function readSignalBody(body, pick, schema) {
185
+ const judged = judgeSignalBody(body, pick, schema);
186
+ return judged.kind === "read" ? judged.value : undefined;
187
+ }
172
188
  /** `pick` for a shape whose keys sit flat on the body itself (as opposed to under one key of it). */
173
189
  const wholeBody = (body) => body;
174
190
  /**
@@ -410,13 +426,12 @@ export const EngineDraining = z.object({
410
426
  * this shape off a body whose keys sit flat on it (no wrapper key).
411
427
  *
412
428
  * Where it rides today: on NEITHER exit whole. The heartbeat ({@link InstanceAnnouncement}) and `/health` ({@link InstanceHealth})
413
- * still speak the 8.0 dialect for the keys they share with this shape, in three ways:
429
+ * still speak the 8.0 dialect for the keys they share with this shape, in two ways:
414
430
  * 1. `draining` is a BOOLEAN there, not the {@link EngineDraining} object. The heartbeat always carries it (`false` included);
415
431
  * `/health` carries `draining: true` only while draining.
416
432
  * 2. `/health` carries `ready` only while NOT ready (`ready: false`); a ready engine's `/health` body has no `ready` key.
417
- * 3. The engine writes `configTargetVersion` / `configAppliedVersion` as numbers; this shape types them as strings.
418
433
  * So `readEngineSignal` over a heartbeat body is always `undefined` (point 1), and over a `/health` body it is `undefined` whenever
419
- * any of the three applies. What does ride both exits flat, under these names and with these values: `restart`, `instanceId`
434
+ * either applies. What does ride both exits flat, under these names and with these values: `restart`, `instanceId`
420
435
  * and the fleet-membership keys `generation` / `leaseState` / `leaseUntil` — on the heartbeat, read them off the parsed
421
436
  * {@link InstanceAnnouncement} (which types all five). The next major retires the boolean pair (`draining` / `drainingSince`),
422
437
  * has both exits carry this shape whole and moves {@link isReadyForTraffic} / {@link isRoutable} onto it; until then this is
@@ -438,7 +453,7 @@ export const EngineDraining = z.object({
438
453
  * - `leaseUntil` — epoch ms until which the engine holds its serving lease, as the engine computed it from the last
439
454
  * `ttlMs` it was granted ({@link ServingLeaseResponse}); present ⇔ `leaseState` is `granted`.
440
455
  * - `configTargetVersion` / `configAppliedVersion` — the config-center version the engine was told to converge on, and the
441
- * one it has applied (opaque strings; equal ⇔ converged).
456
+ * one it has applied: numbers, as the engine mints them (finite, the same rule as {@link RestartSignal}`.version`; equal ⇔ converged).
442
457
  *
443
458
  * Reading: `EngineSignal` is parsed with the default strip — a whole /health body goes in, only these keys come out. A
444
459
  * shape violation in ANY key makes the whole signal `undefined` (it is never "the rest of it"). `undefined` means "no
@@ -454,8 +469,8 @@ export const EngineSignal = z.object({
454
469
  instanceId: z.string().min(1).optional(),
455
470
  leaseState: LeaseState.optional(),
456
471
  leaseUntil: EpochMs.optional(),
457
- configTargetVersion: z.string().min(1).optional(),
458
- configAppliedVersion: z.string().min(1).optional(),
472
+ configTargetVersion: ConfigVersion.optional(),
473
+ configAppliedVersion: ConfigVersion.optional(),
459
474
  });
460
475
  /**
461
476
  * The reader of {@link EngineSignal}: the shape's keys, flat on the given body, judged by the same guarded body reader as
@@ -592,13 +607,15 @@ export function readServingLeaseResponse(body) {
592
607
  * What the CENTER says it can do: capability bits an engine reads before it starts using a center-side endpoint.
593
608
  * - `servingLease` — the center answers serving-lease renewals ({@link ServingLeaseRequest} / {@link ServingLeaseResponse}).
594
609
  *
595
- * A bit is `true` or absent — one spelling for "not supported" (there is no `false`). Absent (an older center, a body without
596
- * the key, a body that cannot be read) means the center does not support the lease, and the engine then neither renews a
597
- * lease nor is fenced by one: this is the compatibility gate that lets an executor ship ahead of the center without pushing
598
- * running engines into lease expiry. It is a gate, not a verdict — nothing in this shape says "the center refuses leases".
599
- * A reader asks `readCenterCapabilities(body)?.servingLease === true` and starts the lease client only on that.
600
- * Unknown bits are stripped (a newer center's extra bits never break an older reader); a known bit whose value is not `true`
601
- * makes the whole read `undefined`, which reads exactly as absent.
610
+ * A bit is `true` or absent — one spelling for "not supported" (there is no `false`). An absent bit — in a declared object, or
611
+ * because the center declares nothing at all (an older center) — means the center does not support the lease, and the engine then
612
+ * neither renews a lease nor is fenced by one: this is the compatibility gate that lets an executor ship ahead of the center
613
+ * without pushing running engines into lease expiry. It is a gate, not a verdict — nothing in this shape says "the center refuses
614
+ * leases". Read a heartbeat reply with {@link readCapabilityReading} and start the lease client only on a `declared` reading whose
615
+ * `capabilities.servingLease === true`.
616
+ * Unknown bits are stripped (a newer center's extra bits never break an older reader); a known bit whose value is not `true` breaks
617
+ * the declaration — and a broken declaration is NOT an absent one: the reply reads as `unreadable`, which is not a reading (the engine
618
+ * keeps the state it had; see {@link readCapabilityReading}).
602
619
  * It rides in ONE place: the heartbeat's 2xx reply ({@link FLEET_PATHS}`.instances`), under {@link HEARTBEAT_REPLY_CAPABILITIES_KEY}.
603
620
  * The center answers the bits per CALLER CREDENTIAL: under a per-worker token, by what the center supports; under the shared token
604
621
  * `servingLease` is always absent (the serving-lease endpoint takes per-worker tokens only, so announcing it there would only push
@@ -609,16 +626,46 @@ export const CenterCapabilities = z.object({
609
626
  });
610
627
  /**
611
628
  * The key of the heartbeat's 2xx reply body under which the center puts its {@link CenterCapabilities} — the object's only carrier.
612
- * The center writes `{ …, [HEARTBEAT_REPLY_CAPABILITIES_KEY]: caps }`; the engine reads
613
- * `readCenterCapabilities(reply[HEARTBEAT_REPLY_CAPABILITIES_KEY])`, and only on a 2xx (a failed heartbeat says nothing about the
614
- * bits). Nested, not flat: the reply's own keys and the capability bits never share a namespace. A reply without the key is an
615
- * older center — no capability said.
629
+ * The center writes `{ …, [HEARTBEAT_REPLY_CAPABILITIES_KEY]: caps }`; the engine reads the whole reply with
630
+ * `readCapabilityReading(reply)` — the key lookup lives in the reader, one carrier one reader — and only on a 2xx (a failed heartbeat
631
+ * says nothing about the bits). Nested, not flat: the reply's own keys and the capability bits never share a namespace.
632
+ * A reply without the key is an older center — no capability said (`undeclared`). A reply with the key present with a value that
633
+ * breaks the shape is NOT "without the key" — it is `unreadable`, and an unreadable reply says nothing: neither declared nor undeclared.
616
634
  */
617
635
  export const HEARTBEAT_REPLY_CAPABILITIES_KEY = "capabilities";
618
- /** Engine side: the center's capability object. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws
619
- * (and `undefined` means "no capability said" — the same as every bit absent). */
620
- export function readCenterCapabilities(body) {
621
- return readSignalBody(body, wholeBody, CenterCapabilities);
636
+ /** The reply body as far as the capability carrier is concerned: the one key, optional, nothing else (the reply's other keys are stripped). */
637
+ const CapabilitiesCarrier = z.object({ [HEARTBEAT_REPLY_CAPABILITIES_KEY]: CenterCapabilities.optional() });
638
+ /**
639
+ * Engine side: what one heartbeat reply says about the center's capabilities. The input is the heartbeat's whole 2xx reply body
640
+ * (the key lookup lives here — one carrier, one reader), the output one of three readings:
641
+ * - `declared` — the key ({@link HEARTBEAT_REPLY_CAPABILITIES_KEY}) is present and its value passes {@link CenterCapabilities};
642
+ * `capabilities` is the parsed object (unknown bits stripped, an absent bit stays absent).
643
+ * - `undeclared` — the reply is an object and the key is absent: an older center, which has said nothing about capabilities.
644
+ * (`undefined` as the key's value is what JSON cannot carry; it reads as the key being absent.)
645
+ * - `unreadable` — the reply is not an object (`why: "not-an-object"`), or the key is present but its value is not a
646
+ * {@link CenterCapabilities} (`why: "invalid-capabilities"`; an accessor that throws reads the same way).
647
+ * Only `declared` and `undeclared` are readings. `unreadable` is not a reading: on the fence axis a malformed declaration must not
648
+ * read as "the center declared nothing", so the engine acts on it as on no reply at all — it keeps the state it last had (before
649
+ * any reading: no lease) and leaves its clocks, the lease expiry and the outage allowance, as they were. Start the lease client only
650
+ * on `declared` with `capabilities.servingLease === true`. Never throws; judged by the same one guarded body judgement as every
651
+ * other reader here.
652
+ */
653
+ export function readCapabilityReading(reply) {
654
+ const judged = judgeSignalBody(reply, wholeBody, CapabilitiesCarrier);
655
+ switch (judged.kind) {
656
+ case "not-an-object":
657
+ return { kind: "unreadable", why: "not-an-object" };
658
+ case "invalid":
659
+ return { kind: "unreadable", why: "invalid-capabilities" };
660
+ case "read": {
661
+ const capabilities = judged.value[HEARTBEAT_REPLY_CAPABILITIES_KEY];
662
+ return capabilities === undefined ? { kind: "undeclared" } : { kind: "declared", capabilities };
663
+ }
664
+ default: {
665
+ const unhandled = judged;
666
+ return unhandled;
667
+ }
668
+ }
622
669
  }
623
670
  /**
624
671
  * The drain endpoint's request: "put this instance into draining, by this deadline". One persisted action, one validation,
package/dist/types.d.ts CHANGED
@@ -17,11 +17,13 @@
17
17
  */
18
18
  import { z } from "zod";
19
19
  /** A private-key PEM header (`-----BEGIN [RSA |EC |OPENSSH |ENCRYPTED ]PRIVATE KEY`), unanchored — the ONE definition
20
- * of that shape: {@link SECRET_LITERAL_RE} is composed from it (anchored, as one of its branches), and
21
- * `redactSecrets` (config-fns) tests it ANYWHERE in a string, on every display-side normalized form. It carries spaces,
22
- * so the token-splitting detector {@link containsSecretToken} only sees it at the very start of a string; a PEM behind
23
- * a newline or a `key: ` prefix slipped past (S-712 codex review [high]), and a `key=`-prefixed percent-encoded one
24
- * hid it behind `%20` on the raw form, a form-urlencoded one behind `+` (6.3.0). Public keys / certificates never match. */
20
+ * of that shape, read ANYWHERE in a string by BOTH judges: the validation detector {@link containsSecretToken}
21
+ * (`scanForSecret`, on every percent layer — 10.0.0) and the display redactor `redactSecrets` (config-fns, on every display-side
22
+ * normalized form). {@link SECRET_LITERAL_RE} is also composed from it (anchored, as one of its branches — the exported per-token
23
+ * literal shape). The header carries spaces, so the token-splitting passes can never isolate it; before 10.0.0 the validation side
24
+ * therefore saw it only at the very start of a string, and a PEM behind a newline, a `key: ` prefix or any other text rode past
25
+ * every free-text write port while the display side already withheld it (a `key=`-prefixed percent-encoded
26
+ * one hid behind `%20` on the raw form, a form-urlencoded one behind `+`, 6.3.0). Public keys / certificates never match. */
25
27
  export declare const PRIVATE_KEY_PEM_HEADER: RegExp;
26
28
  export declare const SECRET_LITERAL_RE: RegExp;
27
29
  export declare const ENV_NAME: z.ZodEffects<z.ZodString, string, string>;
@@ -32,8 +34,9 @@ export declare const ENV_NAME: z.ZodEffects<z.ZodString, string, string>;
32
34
  export declare const hasNul: (s: string) => boolean;
33
35
  /** Does `s` contain a secret-shaped token? Scans every percent layer of it — the raw string AND up to three tolerant
34
36
  * percent-decoded layers (a secret can hide as `%73%6B-…`; the ONE definition of those layers is `percentLayers` in the
35
- * package-private `./secret-scan`) — so mcp args / dockerHost / URL components all get uniform percent-decode coverage.
36
- * Tokenized + JWT-aware (see scanForSecret). Shared by NON_SECRET_URL + dockerHost + mcp stdio. Round-4/5/11. (6.3.0 moved
37
+ * package-private `./secret-scan`) — so mcp args / execution coords / URL components all get uniform percent-decode coverage.
38
+ * Tokenized + JWT-aware (see scanForSecret), plus a private-key PEM header read ANYWHERE in any layer ({@link PRIVATE_KEY_PEM_HEADER},
39
+ * 10.0.0 — the display redactor reads the same regex). Shared by every free-text write port (NON_SECRET_URL, mcp stdio, execution coords, ssh login, worker env, …). Round-4/5/11. (6.3.0 moved
37
40
  * the decode loop out, unchanged in result — same layers, same bound, same verdict — and made it exception-free; the
38
41
  * display-side readings the redactor also judges are not this detector's.) We do NOT fail-closed on a bare `%` — that
39
42
  * would false-reject legit values like `100%` / `%s`. A secret glued to a malformed escape (`%ZZsk-`) is an accepted
@@ -41,7 +44,7 @@ export declare const hasNul: (s: string) => boolean;
41
44
  * R11/R13/R14. */
42
45
  export declare function containsSecretToken(s: string): boolean;
43
46
  /** Scan a URL's PATH + query + fragment (KEYS and values, decoded, tokenized) for a secret-shaped literal.
44
- * Userinfo is handled separately (NON_SECRET_URL bans it; dockerHost scans the ssh user). Round-4/5. */
47
+ * Userinfo is handled separately (NON_SECRET_URL bans it; the ssh username is scanned by `containsSecretToken`). Round-4/5. */
45
48
  export declare function urlQueryFragmentHasSecret(u: URL): boolean;
46
49
  export declare const NON_SECRET_URL: z.ZodEffects<z.ZodString, string, string>;
47
50
  /** Validate a free-form `extraBody`: returns true = UNSAFE (reject). A GETTER-FREE, descriptor-based, bounded
package/dist/types.js CHANGED
@@ -30,11 +30,13 @@ import { LANE_ENV_OWNER, MIGRATION_SIGNPOST, isMigratableEnvKey, laneEnvSeat, la
30
30
  // 0.1.32: this is the root fix for the public-key false-positive — replaces the 0.1.31 name-based exemption,
31
31
  // which review flagged as a bypass (a private key misnamed `*_PUBKEY` skipped scanning entirely).
32
32
  /** A private-key PEM header (`-----BEGIN [RSA |EC |OPENSSH |ENCRYPTED ]PRIVATE KEY`), unanchored — the ONE definition
33
- * of that shape: {@link SECRET_LITERAL_RE} is composed from it (anchored, as one of its branches), and
34
- * `redactSecrets` (config-fns) tests it ANYWHERE in a string, on every display-side normalized form. It carries spaces,
35
- * so the token-splitting detector {@link containsSecretToken} only sees it at the very start of a string; a PEM behind
36
- * a newline or a `key: ` prefix slipped past (S-712 codex review [high]), and a `key=`-prefixed percent-encoded one
37
- * hid it behind `%20` on the raw form, a form-urlencoded one behind `+` (6.3.0). Public keys / certificates never match. */
33
+ * of that shape, read ANYWHERE in a string by BOTH judges: the validation detector {@link containsSecretToken}
34
+ * (`scanForSecret`, on every percent layer — 10.0.0) and the display redactor `redactSecrets` (config-fns, on every display-side
35
+ * normalized form). {@link SECRET_LITERAL_RE} is also composed from it (anchored, as one of its branches — the exported per-token
36
+ * literal shape). The header carries spaces, so the token-splitting passes can never isolate it; before 10.0.0 the validation side
37
+ * therefore saw it only at the very start of a string, and a PEM behind a newline, a `key: ` prefix or any other text rode past
38
+ * every free-text write port while the display side already withheld it (a `key=`-prefixed percent-encoded
39
+ * one hid behind `%20` on the raw form, a form-urlencoded one behind `+`, 6.3.0). Public keys / certificates never match. */
38
40
  export const PRIVATE_KEY_PEM_HEADER = /-----BEGIN[A-Z0-9 ]*PRIVATE KEY/i;
39
41
  export const SECRET_LITERAL_RE = new RegExp(`^(sk-|gh[opus]_|github_pat_|xox[baprse]-|xapp-|bearer\\s|basic\\s|eyJ[A-Za-z0-9_-]{10,}\\.|AKIA[0-9A-Z]{12,}|AIza[0-9A-Za-z_-]{20,}|${PRIVATE_KEY_PEM_HEADER.source})`, "i");
40
42
  // 所有 *Env / *tokenEnv 字段的值是宿主机 env 变量的 NAME(不是值)。合法 env 变量名 + 拒绝 secret 形状的值(AKIA…
@@ -48,6 +50,10 @@ export const ENV_NAME = z
48
50
  // GITHUB_PAT_TOKEN / GHU_TOKEN (which the full SECRET_LITERAL_RE would). Round-5.
49
51
  .refine((s) => !/^AKIA[0-9A-Z]{12,}$/.test(s), "looks like an AWS access-key id, not an env-NAME — put the NAME of the env var here (the value lives in .env)");
50
52
  function scanForSecret(s) {
53
+ // A private-key PEM header ANYWHERE in the string (the one regex the display redactor reads too). The header carries spaces, so
54
+ // neither the start-anchored SECRET_LITERAL_RE below nor the tokenizer at the bottom can see it behind a prefix / newline. 10.0.0.
55
+ if (PRIVATE_KEY_PEM_HEADER.test(s))
56
+ return true;
51
57
  if (SECRET_LITERAL_RE.test(s))
52
58
  return true;
53
59
  // HTTP auth scheme + an opaque token, at a token boundary. SECRET_LITERAL_RE's `bearer\s`/`basic\s` only fire
@@ -77,8 +83,9 @@ function scanForSecret(s) {
77
83
  export const hasNul = (s) => s.includes("\0");
78
84
  /** Does `s` contain a secret-shaped token? Scans every percent layer of it — the raw string AND up to three tolerant
79
85
  * percent-decoded layers (a secret can hide as `%73%6B-…`; the ONE definition of those layers is `percentLayers` in the
80
- * package-private `./secret-scan`) — so mcp args / dockerHost / URL components all get uniform percent-decode coverage.
81
- * Tokenized + JWT-aware (see scanForSecret). Shared by NON_SECRET_URL + dockerHost + mcp stdio. Round-4/5/11. (6.3.0 moved
86
+ * package-private `./secret-scan`) — so mcp args / execution coords / URL components all get uniform percent-decode coverage.
87
+ * Tokenized + JWT-aware (see scanForSecret), plus a private-key PEM header read ANYWHERE in any layer ({@link PRIVATE_KEY_PEM_HEADER},
88
+ * 10.0.0 — the display redactor reads the same regex). Shared by every free-text write port (NON_SECRET_URL, mcp stdio, execution coords, ssh login, worker env, …). Round-4/5/11. (6.3.0 moved
82
89
  * the decode loop out, unchanged in result — same layers, same bound, same verdict — and made it exception-free; the
83
90
  * display-side readings the redactor also judges are not this detector's.) We do NOT fail-closed on a bare `%` — that
84
91
  * would false-reject legit values like `100%` / `%s`. A secret glued to a malformed escape (`%ZZsk-`) is an accepted
@@ -88,7 +95,7 @@ export function containsSecretToken(s) {
88
95
  return percentLayers(s).some((form) => scanForSecret(form));
89
96
  }
90
97
  /** Scan a URL's PATH + query + fragment (KEYS and values, decoded, tokenized) for a secret-shaped literal.
91
- * Userinfo is handled separately (NON_SECRET_URL bans it; dockerHost scans the ssh user). Round-4/5. */
98
+ * Userinfo is handled separately (NON_SECRET_URL bans it; the ssh username is scanned by `containsSecretToken`). Round-4/5. */
92
99
  export function urlQueryFragmentHasSecret(u) {
93
100
  let pathname = u.pathname;
94
101
  try {
@@ -160,7 +167,7 @@ export function extraBodyUnsafe(v) {
160
167
  const t = typeof cur;
161
168
  if (t === "string") {
162
169
  if (containsSecretToken(cur))
163
- return true; // tokenized (mid-string secret too) — sibling of URL/dockerHost/mcp. R8
170
+ return true; // tokenized (mid-string secret too) — sibling of URL/coords/mcp. R8
164
171
  continue;
165
172
  }
166
173
  if (t === "number") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/settings-schema",
3
- "version": "8.3.1",
3
+ "version": "10.0.0",
4
4
  "description": "Sema settings schema — pure config contract (zod domains + effective-config + roster resolution + ref-integrity + remoteExec) shared by sema-registry, sema-server, and the TOC desktop/CLI. One source of truth. (Renamed at 1.0.0 from @sema-agent/registry-core — name and docs only, no behavior change; note the range moves 0.19.0 -> 1.0.0, so a dependency must become ^1.0.0. The old names @sema-agent/registry-core <=0.19.0 and @sema-ai/registry-core remain published, frozen, for migration.)",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",