@sema-agent/settings-schema 8.3.0 → 9.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,115 @@
6
6
  > `@sema-agent/registry-core`(更早还曾是 `@sema-ai/registry-core`)。本档 `0.19.0` 以下各节里的
7
7
  > 包名是**历史记录**,如实保留不追改。
8
8
 
9
+ ## 9.0.0 — 2026-10-09 — 能力读数三态读器 · 世代账两键由串改数(BREAKING)
10
+
11
+ > **判档 = major**(README「发版纪律」:导出删除 / 改名、类型 retype ⇒ major)。**硬 breaking:删改直接做,零别名,消费方 tsc 红即通知。** 两件:
12
+ > ① `readCenterCapabilities` 删,换 `readCapabilityReading` + `CapabilityReading`;② `EngineSignal.configTargetVersion` / `configAppliedVersion` 的类型由 `string` 改 `number`。
13
+ > 导出面登记表:`.` 与 `./fleet` 各 **−1**(`readCenterCapabilities`)**+2**(`readCapabilityReading` 值、`CapabilityReading` 类型),无改形。已知的消费方只有引擎侧(server)。
14
+
15
+ ### 一、能力读数三态读器:`readCapabilityReading(reply): CapabilityReading`(替掉 `readCenterCapabilities`)
16
+
17
+ **病**:`capabilities` 键在场但形坏(例:`{ servingLease: "true" }`、`null`),`readCenterCapabilities` 回 `undefined`;而 `undefined` 在引擎侧的读法是「中心没宣告」⇒ 引擎撤围栏。
18
+ 围栏这条轴上,「形坏」不许等于「缺席」—— 一份读不出的宣告可能是中心正在宣告租约,引擎却据此放掉了围栏。
19
+
20
+ **改**:读器吃心跳 **2xx 的整只答复体**(键查找进读器 —— 一只载体一只读器;不再让调用方自己取 `reply.capabilities`),答闭集三态:
21
+
22
+ - `{ kind: "declared"; capabilities: CenterCapabilities }` —— 键在场且过 `CenterCapabilities` schema(未知位被剥,缺席的位保持缺席;`{}` 也是 declared:宣告了、没有这一位);
23
+ - `{ kind: "undeclared" }` —— 体是对象、键缺席 = 老中心,什么都没说(键的值是 `undefined` 这种 JSON 载不了的形,读成键缺席);
24
+ - `{ kind: "unreadable"; why: "not-an-object" | "invalid-capabilities" }` —— 体不是对象(`null` / 数组 / 串 / 数 / 布尔 / `undefined`),或键在场但 schema 拒(含键上的 accessor 抛)。
25
+
26
+ **引擎侧契约(写进读器头注)**:只有 `declared` / `undeclared` 是读数;**`unreadable` 不是读数** —— 引擎当作没有这一拍答复:保留上一次状态(没有过读数前 = 无租约),
27
+ 租约到期钟与不可达容忍钟照旧走,不因它启动、停止或延长任何东西。同步改真的注:`LEASE_STATES` 的 `none` 句(不再把「读不出」并进没宣告)、`CenterCapabilities` 头注(「读不出」不再算缺席)、
28
+ `HEARTBEAT_REPLY_CAPABILITIES_KEY` 头注(「没有键 = 老中心」保留;补「键在场而形坏 ≠ 没宣告,是 unreadable」)。旧读器的头注随它删除。
29
+
30
+ 内部:`readSignalBody`(各 `read*` 共用的受保护读口)拆出同一个判决核心 `judgeSignalBody`,多答「为什么不是读数」(`not-an-object` / `invalid`);`readSignalBody` 是它的「读到或 `undefined`」面,
31
+ 其余读器一字不变。仍是**一只** `safeParse`、**一只** `catch`(8.1.0 的单点钉不动)。
32
+
33
+ ### 二、世代账两键由串改数:`EngineSignal.configTargetVersion` / `configAppliedVersion`
34
+
35
+ `z.string().min(1)` ⇒ **有限数**(与 `RestartSignal.version` 是同一个量、同一条规则,在 `fleet.ts` 里定义一次,内部常量 `ConfigVersion`,不导出)。理由:引擎 `/health` 铸的就是 number 世代账;
36
+ 8.2.0 起头注就写着「引擎写成数、本形类型为串」并留给下一个 major —— 这里兑现,头注里的「types them as strings」「opaque strings」两句随之删掉,出口方言由三条减为两条
37
+ (布尔 `draining`、就绪时 `/health` 不带 `ready`)。
38
+
39
+ 读数变化:带**数形**世代账的 `/health` 体,补上 `ready` 后 `readEngineSignal` 现在读得出(以前恒 `undefined`);带**串形**世代账的体现在读成 `undefined`(整只信号拒,不拿剩下的充数);
40
+ `NaN` / `±Infinity` 拒(JSON 本来也载不了)。`InstanceAnnouncement` 一直不收这两键(strip),未动。
41
+
42
+ ### 用户可见变化(谁受影响 / 怎么改 / 不改会怎样)
43
+
44
+ 1. **引擎侧租约客户端(server)**:调 `readCenterCapabilities` 的 ⇒ tsc 红(没有这个导出了)。迁移一句:
45
+ `readCenterCapabilities(reply.capabilities)?.servingLease` ⇒ `const r = readCapabilityReading(reply); r.kind === "declared" && r.capabilities.servingLease`;
46
+ 并对 `unreadable` 按契约不动状态与钟(旧代码里它与 `undeclared` 是同一个 `undefined`,所以以前会撤围栏)。对 `r.kind` 做 `switch` 时加 `default: never` 臂,以后加态编译会红。
47
+ 2. **读 `EngineSignal.configTargetVersion` / `configAppliedVersion` 的**:类型由 `string | undefined` 变 `number | undefined` ⇒ 把它当串用的 tsc 红;按数比较 / 展示即可(「equal ⇔ converged」不变)。
48
+ 拿 `readEngineSignal` 读带串形世代账的体的,现在得 `undefined`。
49
+ 3. **其它消费方**(管理面 / sdk / 只用 schema 与读写 helper 的):零变化 —— 据发版方核对,它们没有这两处的读点。
50
+ 4. **持有导出面快照的测试 / 脚本**:`readCenterCapabilities` 行删,`readCapabilityReading`(值)与 `CapabilityReading`(类型)两行加。
51
+
52
+ ### 红先 / 门
53
+
54
+ - 先写钉、对着改前的树跑红,再实现:`test/fleet-9.0.0.test.ts` 24 格(三态各一格 + 老中心 `{ ok: true }` ⇒ `undeclared`、`{ capabilities: { servingLease: "true" } }` ⇒ `unreadable` / `invalid-capabilities`、
55
+ `null` / `[]` / 串 ⇒ `unreadable` / `not-an-object`、`{ capabilities: { servingLease: true, extra: 1 } }` 按 `CenterCapabilities` 现行宽严(未知位被剥)⇒ `declared`;敌意 accessor / Proxy 不抛;
56
+ 穷举 `switch` 编译钉;旧读器从两个入口都删;五处注释文本钉;世代账两键:数收、串 / 空串 / 非有限 / 空值拒,类型面,金样 `/health` 体,与 `RestartSignal.version` 同判,头注两句已删)⇒ 写钉时对改前的树 23 格里 21 红 2 绿
57
+ (2 绿是不依赖新读器的反向格),随后补一格(键的值是 `undefined` 读成键缺席);实装后 24 格全绿。
58
+ - 既有钉随之改真:`test/engine-signal-8.1.0.test.ts`(世代账夹具与类型面改数;「形坏读成 `undefined`,读方等同缺席」那格翻成 `unreadable`,不再等同缺席;读器三格改用新读器)、
59
+ `test/fleet-8.2.0.test.ts`(能力位金样改读整只答复体;「补上 ready 也仍因数形的世代账读不出」那格翻成读得出)。
60
+ - 变异自证(九个,每个都使预期那几格红):无效声明读成 `undeclared`(旧病)/ 非对象读成 `undeclared` / 载体不过 schema / `ConfigVersion` 丢 `.finite()` /
61
+ `configTargetVersion` 退回串 / 读器头注删「`unreadable` 不是读数」句 / 键缺席读成 `unreadable` / 旧读器别名回来 / `EngineSignal` 头注留着「types them as strings」。全量读数见发版链(本节不复述会过期的数)。
62
+ - 导出面登记表(`docs/API-SURFACE.md`)重生成:`.` 与 `./fleet` 各 −1 +2。
63
+
64
+ ### 未闭环(如实)
65
+
66
+ - **引擎对 `unreadable` 的处置是契约不是本包能判的**:「保留上一次状态与钟」写在读器头注里,本包没有取引擎源码的通道,没有机器门看引擎是否照做。
67
+ - **`EngineSignal` 仍未被任何出口整只承载**:心跳的布尔 `draining`、`/health` 就绪时不带 `ready`,以及 `isReadyForTraffic` / `isRoutable` 仍吃布尔方言 —— 头注与 8.2.0 起都写「下一个 major」收;
68
+ 本版只兑现了世代账两键,这几件仍待下一个 major。`InstanceHealth` 的 `restart` 仍未入形(同样留给下一个 major)。
69
+ - **读器不判中心与引擎的凭证对应**:`declared` 里的位按调用方凭证答(共享令牌看不到 `servingLease`)是中心的事,本读器只读答复体里有什么。
70
+
71
+ ## 8.3.1 — 2026-10-08 — FLEET_SCOPE_ENV 注改真(patch)
72
+
73
+ > **判档 = patch**(README「发版纪律」:文案 / 注释 = patch)。只改 `src/fleet.ts` 里 `FLEET_SCOPE_ENV` 的头注(它随 `.d.ts` 发出);常量值不变,导出面登记表零 diff,零运行期行为,
74
+ > 零 wire 形。**8.3.0 已发的那一节一字未动**(按包规则,对已发节的更正写进当前节)—— 它里面作废的说法在下面逐句点名。
75
+
76
+ ### 一、病:8.3.0 的头注把续租体与心跳说成引擎用这只 env 填的东西
77
+
78
+ 8.3.0 的头注(与同节第一节)写「引擎读它作自身身份的 scope:比对 `DrainRequest.scope`、作 `ServingLeaseRequest.scope` 发出、在心跳上点名(`?scope=`)」。
79
+ 后两处与引擎的实现相反:引擎读这只 env **只**用来比对 `DrainRequest.scope`(请求不带 scope ⇒ 读成默认 scope,与引擎自己缺席时读的是同一只缺省);
80
+ **续租体与心跳都不另送 scope** —— 那两处的 scope 由凭证绑定决定。这条规则本包一直写在 `ServingLeaseRequest` 注(续租只收 per-worker 令牌,缺席 = 令牌的 scope,在场必须相等)与
81
+ `InstanceAnnouncement` 注(心跳体没有 `scope` 键,行落在凭证点名的 scope)上,8.3.0 的 `FLEET_SCOPE_ENV` 头注与它们自相矛盾。
82
+
83
+ ### 二、8.3.0 一节里作废的三句(同一个说法)
84
+
85
+ 1. 第一节「引擎读它作**自身身份的 scope**:排空口里拿 `DrainRequest.scope` 比对的那一格、续租体 `ServingLeaseRequest.scope`、心跳的 `?scope=`」—— 后两项作废,只剩第一项。
86
+ 2. 用户可见变化第 2 条的括注「(续租体「在场必须相等」的规则下会被拒)」—— 作废;没渲 `FLEET_SCOPE` 的后果见下面第四节。
87
+ 3. 未闭环第二条「不一致时按各口既有的规则表现为拒(续租体 scope 在场必须与令牌绑定的相等)/ 比对不上」里的「续租体 … 拒」—— 作废,只剩「比对不上」。
88
+
89
+ ### 三、现读法(一个事实一个源)
90
+
91
+ - **续租 / 心跳的 scope = 凭证**:续租只收 per-worker 令牌,`ServingLeaseRequest.scope` 缺席 = 令牌绑定的 scope、在场必须相等(令牌与体字段之间的规则,不涉 `FLEET_SCOPE`,未变);
92
+ 心跳体没有 `scope` 键,行落在凭证点名的 scope。引擎不用 `FLEET_SCOPE` 填这两处。
93
+ - **引擎自身身份的 scope = 渲染器给的 `FLEET_SCOPE`**(缺席 = `GLOBAL_SCOPE`;不从令牌格式反推),唯一用途 = 比对 `DrainRequest.scope`(请求不带 scope ⇒ 读成默认 scope)。
94
+ - **两者同值是渲染器的义务**:本包不判、也判不了(令牌不在本包)。
95
+
96
+ 头注改成这一读法;文本钉见第五节。README 的 8.3.0 条目与 llms 事实版没有复述作废的那句;`llms-full.txt` 的 `FLEET_SCOPE_ENV` 事实句补写了「只用来比对 `DrainRequest.scope`、续租 / 心跳的 scope 由凭证定」。
97
+
98
+ ### 四、用户可见变化(谁受影响 / 怎么改 / 不改会怎样)
99
+
100
+ 1. **只用 schema / 读写 helper、不碰 fleet 的消费方**:零变化(注释改动,导出面零 diff)。
101
+ 2. **配置渲染器(管理面)**:渲 `FLEET_SCOPE` 的义务不变,值 = 该成员 per-worker 令牌绑定的 scope。现读法下它**只**影响引擎对排空请求 scope 的比对:
102
+ 不渲 ⇒ 引擎身份按 `GLOBAL_SCOPE`,非 `global` 租户的排空请求(带该租户 scope)与它比对不上;**续租与心跳不受影响**(它们的 scope 来自凭证,不来自这只 env)。
103
+ 3. **引擎(server)**:不要把 `FLEET_SCOPE` 填进续租体或心跳;引擎现有的读法(只比对 `DrainRequest.scope`)不变,本版只是把本包的注改成与它一致。
104
+
105
+ ### 五、红先 / 门
106
+
107
+ - 先写钉、对着改前的注跑红,再改注:`test/fleet-scope-env-8.3.1.test.ts` 五格(常量不动 / 头注不再说「sends as」「names on its heartbeat」「?scope=」,且凡提到续租 / 心跳的句子同句带「credential」/
108
+ 头注把用途钉在比对 `DrainRequest.scope` 并写明请求不带 scope 读成默认 scope / 头注一句话说清「续租 / 心跳的 scope = 凭证;引擎自身身份的 scope = 渲染器给的 `FLEET_SCOPE`;同值是渲染器的义务」/
109
+ 8.3.0 既有三件仍在)⇒ 3 红 2 绿(2 绿是反向格:常量值不动、既有三件不丢);改注后全绿;`test/fleet-scope-env-8.3.0.test.ts` 不动、仍绿。
110
+ - 变异自证(每个都使预期那一格红):头注改回「sends as … ServingLeaseRequest.scope」/ 头注删「the credential's」句 / 头注删「renderer's obligation」句 / 头注删「carries no scope reads as the default scope」/ 头注写回 `?scope=`。
111
+ - 全量读数见发版链(本节不复述一份会过期的数)。
112
+
113
+ ### 未闭环(如实)
114
+
115
+ - **现读法里引擎侧的事实**(只比对 `DrainRequest.scope`、请求不带 scope 读缺省、续租体与心跳不另送 scope)出自引擎实现;本包没有取引擎源码的通道,没有机器门看「引擎改了读法而本注没跟」。
116
+ - **8.3.0 已发节里作废的三句仍留在那一节**(已发节字节不动):读者以本节为准。
117
+
9
118
  ## 8.3.0 — 2026-10-08 — fleet 成员所属租户 scope 的 env 名常量 `FLEET_SCOPE_ENV`(additive)
10
119
 
11
120
  > **判档 = minor**(README「发版纪律」:新键 / 新导出 = minor)。新导出 1 名(`FLEET_SCOPE_ENV`,`.` 与 `./fleet` 各 +1)与一个新保留 env 名;
package/README.md CHANGED
@@ -48,6 +48,25 @@ 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
+ ## 升级指引:8.3.1 → 9.0.0(BREAKING:两件,硬 breaking —— 删改直接做,零别名)
52
+
53
+ **① 能力读数改三态读器**:`readCenterCapabilities(body)` 删,换 `readCapabilityReading(reply): CapabilityReading`(类型 `CapabilityReading` 同入)。输入改成心跳 **2xx 的整只答复体**(键查找进读器),
54
+ 答三态:`{ kind: "declared"; capabilities }`(`capabilities` 键在场且过 schema)· `{ kind: "undeclared" }`(体是对象、键缺席 = 老中心)·
55
+ `{ kind: "unreadable"; why: "not-an-object" | "invalid-capabilities" }`(体不是对象 / 键在场但 schema 拒)。**只有 `declared` / `undeclared` 是读数;`unreadable` 不是读数 —— 引擎保留上一次状态与钟。**
56
+
57
+ - **谁需要**:引擎侧的租约客户端(server)—— 围栏这条轴上「形坏」不许等于「缺席」:以前 `capabilities` 键在场但形坏读成 `undefined` = 「没宣告」,引擎据此撤围栏。
58
+ - **谁受伤**:调 `readCenterCapabilities` 的(tsc 红:没有这个导出了)。其它消费方(管理面 / sdk)据发版方核对没有读点。
59
+ - **迁移一句**:`readCenterCapabilities(reply.capabilities)?.servingLease` ⇒ `const r = readCapabilityReading(reply); r.kind === "declared" && r.capabilities.servingLease`;
60
+ 并对 `unreadable` 按契约不动状态与钟(旧代码里它和 `undeclared` 是同一个 `undefined`)。
61
+
62
+ **② `EngineSignal.configTargetVersion` / `configAppliedVersion` 由串改数**(有限数,与 `RestartSignal.version` 同一条规则):引擎 `/health` 铸的就是 number 世代账。
63
+ - **谁受伤**:把这两键当 `string` 用的(tsc 红);拿 `readEngineSignal` 读带串形世代账的体的(现在读成 `undefined`)。反过来,以前因数形世代账而读不出的 `/health` 体(补上 `ready` 后)现在读得出。
64
+ - **补偿**:按数用即可,没有别的动作。逐条见 CHANGELOG 9.0.0。
65
+
66
+ ## 升级指引:8.3.0 → 8.3.1(注释改真,零动作可升)
67
+
68
+ `FLEET_SCOPE_ENV` 的头注改真:引擎读这只 env 只用来比对 `DrainRequest.scope`;续租体与心跳的 scope 由凭证定、不取这只 env。导出面与行为零变化,逐条见 CHANGELOG 8.3.1。
69
+
51
70
  ## 升级指引:8.2.0 → 8.3.0(additive,零动作可升)
52
71
 
53
72
  `./fleet`(与主入口)多一个 env 名常量 `FLEET_SCOPE_ENV = "FLEET_SCOPE"`:成员所属租户 scope,渲染器写、引擎读(缺席 = `GLOBAL_SCOPE`),同时进 `RESERVED_WORKER_ENV`(`worker.env` / `secretEnv` 设它被拒)。逐条见 CHANGELOG 8.3.0。
@@ -87,12 +106,12 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
87
106
  - `RestartSignal.withheld?` —— 可选布尔,引擎自判「这一拍不该被换代」;缺席 = `false`,读出形不铸键。
88
107
  - `EngineSignal`(+ `EngineDraining`)与 `readEngineSignal(health)` —— 引擎自铸信号的**闭形**(`ready` 必填,`draining?` / `restart?` / `generation?` / `instanceId?` /
89
108
  `leaseUntil?` / `configTargetVersion?` / `configAppliedVersion?` 可选);与既有读器走同一只受保护读口。⚠️ 8.2.0 订正:今天的心跳与 `/health` 都不整只
90
- 承载它(心跳恒带布尔 `draining`、`/health` 就绪时不带 `ready`、世代账两键是数),`readEngineSignal` 对今天的心跳体恒 `undefined` —— 见 8.2.0 升级指引。
109
+ 承载它(心跳恒带布尔 `draining`、`/health` 就绪时不带 `ready`、世代账两键是数),`readEngineSignal` 对今天的心跳体恒 `undefined` —— 见 8.2.0 升级指引(9.0.0 起这两键在 `EngineSignal` 里就是数,不再是读不出的原因)。
91
110
  `undefined` = 没有这只信号,**不是就绪**;布尔 `draining` 的旧方言体读不出它(两个就绪谓词的输入仍是布尔方言,下一个 major 收)。
92
111
  - 代际 —— `generation` 是非负整数的**规范十进制串**(`"7"`,不是 `"07"`、不是数字型),判别器 `isCanonicalGeneration`、数值比较 `compareGeneration`;三处 wire 形共用这一条判据。
93
112
  - 服务租约 API 型 —— `ServingLeaseRequest`、`ServingLeaseResponse`(`granted { ttlMs }` / `denied { reason: superseded | retired }` / `unknown` 三词闭集,
94
113
  **`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`;
95
- 中心能力位 `CenterCapabilities { servingLease?: true }` + `readCenterCapabilities` —— 租约客户端只在位在场时启用,缺席 = 中心不支持租约(不续租、不围栏)。
114
+ 中心能力位 `CenterCapabilities { servingLease?: true }` + `readCenterCapabilities`(9.0.0 起删,由 `readCapabilityReading` 取代,见 9.0.0 升级指引)—— 租约客户端只在位在场时启用,缺席 = 中心不支持租约(不续租、不围栏)。
96
115
  - 排空口型 —— `DrainRequest`、`DrainReceipt`(`state: draining | already_draining`)、读器 `readDrainRequest` / `readDrainReceipt`。
97
116
  - `FLEET_GENERATION_ENV` —— 配置渲染器与引擎共用的 generation env 名常量(实例身份 `instanceId` 由引擎每进程自铸,没有 env 名);它同时进了 `RESERVED_WORKER_ENV`,`worker.env` / `secretEnv` 设不了它。
98
117
  - `clampServingLeaseTtlMs(n)` —— 配置的租约 TTL 的唯一夹取点(读不出 ⇒ 缺省 600 000;低于地板 ⇒ 60 000;无上限)。
@@ -350,6 +369,8 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
350
369
  the center's capability bits: `HEARTBEAT_REPLY_CAPABILITIES_KEY` (`"capabilities"`) on the heartbeat's 2xx reply.
351
370
  **8.3.0** (additive) adds the env name `FLEET_SCOPE_ENV` (`"FLEET_SCOPE"`) — the tenant scope a fleet member belongs to, set by the
352
371
  config renderer and read by the engine as its own identity's scope (absent ⇒ `GLOBAL_SCOPE`); a reserved worker env name.
372
+ **9.0.0** (BREAKING) replaces `readCenterCapabilities` with the three-state reader `readCapabilityReading(reply)` (`declared` |
373
+ `undeclared` | `unreadable` — a malformed declaration is not an absent one) and types `EngineSignal`'s two config-version keys as numbers.
353
374
  - **Declared-but-unvalidated keys** (`PARSE_TRANSPARENT_KEYS`, 2.0.0): the ONE registry of keys this package
354
375
  carries verbatim WITHOUT checking their shape, each row naming who does check it (the engine's ingest side or
355
376
  a consumer's enforcement point). Today: `models[].compat` (1.10.0), `models[].headers` (6.4.0; the header-name
@@ -1 +1 @@
1
- 8.3.0
1
+ 9.0.0
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
  /**
@@ -715,13 +717,15 @@ export declare const FLEET_GENERATION_ENV = "FLEET_GENERATION";
715
717
  /**
716
718
  * The env name a fleet member's tenant scope travels under — the config renderer sets it to the scope this member's per-worker
717
719
  * token binds (the same value, taken from the same place the token is issued for), and the engine reads it as the scope of its
718
- * OWN identity: the scope it compares a {@link DrainRequest}'s `scope` against, sends as {@link ServingLeaseRequest}`.scope`, and
719
- * names on its heartbeat ({@link InstanceAnnouncement}'s `?scope=`). Both sides import THIS constant; neither spells the name.
720
- * One fact, one source: the scope is GIVEN by the renderer — the engine never derives it from the token's format (a token is
721
- * opaque to it, and a format guess would be a second source that can disagree with the scope the center binds). Absent from
722
- * the engine's env ⇒ the default scope, {@link GLOBAL_SCOPE} (`api/scopes`) — the same default the center reads where nothing
723
- * names a scope; this note does not restate the value. Like {@link FLEET_GENERATION_ENV} it is a reserved worker env name
724
- * (`WorkerSpec.env` / `secretEnv` cannot set it).
720
+ * OWN identity, for one use: the scope it compares a {@link DrainRequest}'s `scope` against (a request that carries no scope
721
+ * reads as the default scope — the same default the engine's own absence reads as). Both sides import THIS constant; neither
722
+ * spells the name. One fact, one source: a lease's and the heartbeat's scope is the credential's (the credential decides it, see
723
+ * {@link ServingLeaseRequest} and {@link InstanceAnnouncement}; nothing in the engine's env does); the engine's own identity
724
+ * scope is the one the renderer gave in this env; that the two are the same value is the renderer's obligation.
725
+ * The engine never derives it from the token's format (a token is opaque to it, and a format guess would be a second source
726
+ * that can disagree with the scope the center binds). Absent from the engine's env ⇒ the default scope, {@link GLOBAL_SCOPE} (`api/scopes`) —
727
+ * the same default the center reads where nothing names a scope; this note does not restate the value. Like
728
+ * {@link FLEET_GENERATION_ENV} it is a reserved worker env name (`WorkerSpec.env` / `secretEnv` cannot set it).
725
729
  */
726
730
  export declare const FLEET_SCOPE_ENV = "FLEET_SCOPE";
727
731
  /**
@@ -879,13 +883,15 @@ export declare function readServingLeaseResponse(body: unknown): ServingLeaseRea
879
883
  * What the CENTER says it can do: capability bits an engine reads before it starts using a center-side endpoint.
880
884
  * - `servingLease` — the center answers serving-lease renewals ({@link ServingLeaseRequest} / {@link ServingLeaseResponse}).
881
885
  *
882
- * A bit is `true` or absent — one spelling for "not supported" (there is no `false`). Absent (an older center, a body without
883
- * the key, a body that cannot be read) means the center does not support the lease, and the engine then neither renews a
884
- * lease nor is fenced by one: this is the compatibility gate that lets an executor ship ahead of the center without pushing
885
- * running engines into lease expiry. It is a gate, not a verdict — nothing in this shape says "the center refuses leases".
886
- * A reader asks `readCenterCapabilities(body)?.servingLease === true` and starts the lease client only on that.
887
- * Unknown bits are stripped (a newer center's extra bits never break an older reader); a known bit whose value is not `true`
888
- * 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}).
889
895
  * It rides in ONE place: the heartbeat's 2xx reply ({@link FLEET_PATHS}`.instances`), under {@link HEARTBEAT_REPLY_CAPABILITIES_KEY}.
890
896
  * The center answers the bits per CALLER CREDENTIAL: under a per-worker token, by what the center supports; under the shared token
891
897
  * `servingLease` is always absent (the serving-lease endpoint takes per-worker tokens only, so announcing it there would only push
@@ -901,15 +907,39 @@ export declare const CenterCapabilities: z.ZodObject<{
901
907
  export type CenterCapabilities = z.infer<typeof CenterCapabilities>;
902
908
  /**
903
909
  * The key of the heartbeat's 2xx reply body under which the center puts its {@link CenterCapabilities} — the object's only carrier.
904
- * The center writes `{ …, [HEARTBEAT_REPLY_CAPABILITIES_KEY]: caps }`; the engine reads
905
- * `readCenterCapabilities(reply[HEARTBEAT_REPLY_CAPABILITIES_KEY])`, and only on a 2xx (a failed heartbeat says nothing about the
906
- * bits). Nested, not flat: the reply's own keys and the capability bits never share a namespace. A reply without the key is an
907
- * 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.
908
915
  */
909
916
  export declare const HEARTBEAT_REPLY_CAPABILITIES_KEY = "capabilities";
910
- /** Engine side: the center's capability object. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws
911
- * (and `undefined` means "no capability said" — the same as every bit absent). */
912
- 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;
913
943
  /**
914
944
  * The drain endpoint's request: "put this instance into draining, by this deadline". One persisted action, one validation,
915
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
@@ -477,13 +492,15 @@ export const FLEET_GENERATION_ENV = "FLEET_GENERATION";
477
492
  /**
478
493
  * The env name a fleet member's tenant scope travels under — the config renderer sets it to the scope this member's per-worker
479
494
  * token binds (the same value, taken from the same place the token is issued for), and the engine reads it as the scope of its
480
- * OWN identity: the scope it compares a {@link DrainRequest}'s `scope` against, sends as {@link ServingLeaseRequest}`.scope`, and
481
- * names on its heartbeat ({@link InstanceAnnouncement}'s `?scope=`). Both sides import THIS constant; neither spells the name.
482
- * One fact, one source: the scope is GIVEN by the renderer — the engine never derives it from the token's format (a token is
483
- * opaque to it, and a format guess would be a second source that can disagree with the scope the center binds). Absent from
484
- * the engine's env ⇒ the default scope, {@link GLOBAL_SCOPE} (`api/scopes`) — the same default the center reads where nothing
485
- * names a scope; this note does not restate the value. Like {@link FLEET_GENERATION_ENV} it is a reserved worker env name
486
- * (`WorkerSpec.env` / `secretEnv` cannot set it).
495
+ * OWN identity, for one use: the scope it compares a {@link DrainRequest}'s `scope` against (a request that carries no scope
496
+ * reads as the default scope — the same default the engine's own absence reads as). Both sides import THIS constant; neither
497
+ * spells the name. One fact, one source: a lease's and the heartbeat's scope is the credential's (the credential decides it, see
498
+ * {@link ServingLeaseRequest} and {@link InstanceAnnouncement}; nothing in the engine's env does); the engine's own identity
499
+ * scope is the one the renderer gave in this env; that the two are the same value is the renderer's obligation.
500
+ * The engine never derives it from the token's format (a token is opaque to it, and a format guess would be a second source
501
+ * that can disagree with the scope the center binds). Absent from the engine's env ⇒ the default scope, {@link GLOBAL_SCOPE} (`api/scopes`) —
502
+ * the same default the center reads where nothing names a scope; this note does not restate the value. Like
503
+ * {@link FLEET_GENERATION_ENV} it is a reserved worker env name (`WorkerSpec.env` / `secretEnv` cannot set it).
487
504
  */
488
505
  export const FLEET_SCOPE_ENV = "FLEET_SCOPE";
489
506
  /**
@@ -590,13 +607,15 @@ export function readServingLeaseResponse(body) {
590
607
  * What the CENTER says it can do: capability bits an engine reads before it starts using a center-side endpoint.
591
608
  * - `servingLease` — the center answers serving-lease renewals ({@link ServingLeaseRequest} / {@link ServingLeaseResponse}).
592
609
  *
593
- * A bit is `true` or absent — one spelling for "not supported" (there is no `false`). Absent (an older center, a body without
594
- * the key, a body that cannot be read) means the center does not support the lease, and the engine then neither renews a
595
- * lease nor is fenced by one: this is the compatibility gate that lets an executor ship ahead of the center without pushing
596
- * running engines into lease expiry. It is a gate, not a verdict — nothing in this shape says "the center refuses leases".
597
- * A reader asks `readCenterCapabilities(body)?.servingLease === true` and starts the lease client only on that.
598
- * Unknown bits are stripped (a newer center's extra bits never break an older reader); a known bit whose value is not `true`
599
- * 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}).
600
619
  * It rides in ONE place: the heartbeat's 2xx reply ({@link FLEET_PATHS}`.instances`), under {@link HEARTBEAT_REPLY_CAPABILITIES_KEY}.
601
620
  * The center answers the bits per CALLER CREDENTIAL: under a per-worker token, by what the center supports; under the shared token
602
621
  * `servingLease` is always absent (the serving-lease endpoint takes per-worker tokens only, so announcing it there would only push
@@ -607,16 +626,46 @@ export const CenterCapabilities = z.object({
607
626
  });
608
627
  /**
609
628
  * The key of the heartbeat's 2xx reply body under which the center puts its {@link CenterCapabilities} — the object's only carrier.
610
- * The center writes `{ …, [HEARTBEAT_REPLY_CAPABILITIES_KEY]: caps }`; the engine reads
611
- * `readCenterCapabilities(reply[HEARTBEAT_REPLY_CAPABILITIES_KEY])`, and only on a 2xx (a failed heartbeat says nothing about the
612
- * bits). Nested, not flat: the reply's own keys and the capability bits never share a namespace. A reply without the key is an
613
- * 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.
614
634
  */
615
635
  export const HEARTBEAT_REPLY_CAPABILITIES_KEY = "capabilities";
616
- /** Engine side: the center's capability object. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws
617
- * (and `undefined` means "no capability said" — the same as every bit absent). */
618
- export function readCenterCapabilities(body) {
619
- 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
+ }
620
669
  }
621
670
  /**
622
671
  * The drain endpoint's request: "put this instance into draining, by this deadline". One persisted action, one validation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/settings-schema",
3
- "version": "8.3.0",
3
+ "version": "9.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",