@sema-agent/settings-schema 3.0.1 → 5.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,337 @@
6
6
  > `@sema-agent/registry-core`(更早还曾是 `@sema-ai/registry-core`)。本档 `0.19.0` 以下各节里的
7
7
  > 包名是**历史记录**,如实保留不追改。
8
8
 
9
+ ## 5.0.0 — 2026-09-23 — `DeviceCodeResponse.verification_uri_complete` 改为**可选**(BREAKING,校验放宽;S-615)
10
+
11
+ > **版号裁定(发布方)**:本批只有一处改动 —— 设备码授权响应里 `verification_uri_complete` 由必填改为可选。
12
+ > 照下方「发版纪律」的「校验收紧**或放宽** = major」判 **5.0.0**:按上一版契约写的读方可能把它当恒在的
13
+ > `string` 用,放宽后同一份响应在本包下解析得出、到它那里却是 `undefined`。**零别名、零双读**:推导型由
14
+ > `string` 变 `string | undefined`,读方若把它当 `string` 直接用会在 tsc 上红 —— 那一格红就是通知。
15
+ > 持久数据面**零影响**(不碰任何存储形)。
16
+
17
+ **三问**:
18
+ - **谁需要** = 读方与 RFC 8628 §3.2 对齐:该字段在 RFC 里是 **OPTIONAL**。本包此前写成 `z.string().min(1)`
19
+ 必填,而 CLI 读方在运行期本来就按「可能没有」加了守卫 —— 型面说必填、运行期说可缺,是同一件事的两种读法
20
+ (clay 裁 C-R91 C⑥:型面改可选,单源说真话)。
21
+ - **谁受伤** = 把它当恒在 `string` 读的消费方。逐仓亲读(`git grep`,不含 test/ 与 dist):**生产方**管理台
22
+ `sema-web-admin` 2 处(`src/app/api/v1/auth/device/code/route.ts` 照发不变、`src/app/activate/page.tsx`);
23
+ **读方**CLI `sema-cli` 1 处真读(`src/cli/handlers/cloud.ts:70`,已有 `if (device.verification_uri_complete)`
24
+ 守卫,另 `src/sema/cloudAuth.ts:4` 是注释)、client-core `src/model/providerAuth.ts:174` 读的是它**自己声明**的
25
+ `verification_uri_complete?: unknown`(不经本包推导型,已守卫);SDK 源码 0 处(只有 `docs/manual/ch7-registry-control.md:59`
26
+ 文档一行与 3 处测试夹具);worker 服务 / core / 测试仓 0。⇒ 预计**零处** tsc 红;SDK 手册那一行要跟着说「可选」。
27
+ - **什么补偿** = 生产方行为不变(管理台照发该字段);读方的正确读法 = 「在场 ⇒ 一键 / 二维码路径;缺席 ⇒ 展示
28
+ `verification_uri` + `user_code`」。**在场但为空串仍拒**(`min(1)` 不变)—— 可选是本批**唯一**的放宽。
29
+
30
+ **迁移三句**:① 读 `verification_uri_complete` 的代码按 `string | undefined` 处理(缺席时退回 `verification_uri`
31
+ + `user_code`);② 生产方无需改动;③ 手写了该响应 JSON Schema / OpenAPI 的消费方把它移出 `required`
32
+ (本包 `docs/openapi-auth.yaml` 已同步)。
33
+
34
+ **红先**:`test/api-auth.test.ts` 新格(缺席可解析 / 在场原样 / 空串仍拒)修前 **1 failed / 34 passed**
35
+ (缺席被拒 `Required`)→ 修后 35/35。
36
+
37
+ ## 4.0.0 — 2026-09-22 — 引用完整性的**域闭集发成运行期值**(additive)+ `RefIssue.path` 六臂坐标**统一成以域键起手**(BREAKING,值形)+ 诊断面回**真实总数**(BREAKING,返回形)+ 告警投影改**机器派生**并搬回属主(BREAKING,模块面)+ `fileSchedulerStore` → `createFileSchedulerStore`(BREAKING,重命名,零别名)+ 一致性门来源律改**运行期拒**+ prompts 摘要搬出调度存储并合并铸点 + 四道机器门
38
+
39
+ > **版号裁定(发布方)**:本批两件里有一件是**值形**破坏性改动 —— `refIntegrityIssues` 返回的
40
+ > `RefIssue.path` 在 `collab` / `entitlement` 两臂上换了首段。照本档下方「发版纪律」的「**改形 = major**」
41
+ > 判 **4.0.0**。**零别名、零双读、零过渡方法**:值形改动不会让消费方 tsc 红(`path` 的类型一字未变),
42
+ > 所以下方迁移句给出**可 grep 的旧形特征**与逐处坐标——这是本批唯一需要人看一眼的地方。
43
+ > 持久数据面**零影响**:不碰任何存储形,`config.d/*.json` 与 `/effective` 载荷一字未变。
44
+
45
+ **三问**:
46
+ - **谁需要** = ① 任何在**运行期**问「这个域要不要过引用完整性闸」的消费方:闭集以前只活在类型里
47
+ (`RefCheckedDomain` 是一条手写联合,运行期没有值),于是每个消费方只能自己抄一份域名清单,而手抄的
48
+ 清单会静默漂 —— 管理台的两条回滚路由各抄了一份**少 `entitlement`** 的五员集合
49
+ (`sema-web-admin` `src/app/api/config/rollback/route.ts:18` 的 `REF_CHECKED` Set 与
50
+ `src/app/api/v1/scopes/[scope]/rollback/route.ts:89` 的行内联合),`entitlement` 域的回滚因此整条**绕过**
51
+ 引用完整性闸,而全舰队没有一格红;② 拿 `path` 拼坐标的写腿:六臂两形,`path[0]` 读不出是哪个域。
52
+ - **谁受伤** = 两个消费方:管理台(`sema-web-admin`)与 worker 服务(`sema-server`)。逐只亲读实测
53
+ (`refIntegrityIssues`:**管理台 17**(`src/`;另 `test/` 5)、**worker 服务 2**、CLI 0、client SDK 0、
54
+ SDK / 桌面 / web 客户端 / 测试仓各 0;`RefIssue` 与 `RefCheckedDomain` 各**只**管理台 1 处 ——
55
+ `src/lib/config/cross-domain.ts:8` 的再导出)。
56
+ worker 服务那 2 处是**真生产调用**,不是注释:`src/config-provider.ts:45` 的 import 与 `:272` 的调用,
57
+ 本地读腿每次 `fetchEffective` 都会跑,并且它 ①`:271` 自己手抄了一份六员域名清单(第三份手抄闭集,
58
+ 改调 `REF_CHECKED_DOMAINS` 即可退役)、②`:274` 用 `i.path.join(".")` 拼运维可见的
59
+ `domainErrors[].error`。所以 4.0.0 之后它那条串对 `collab` / `entitlement` 两域会多一段域键前缀
60
+ (`tiers.0.roster` → `entitlement.tiers.0.roster`);它现有的钉只覆盖 `rosters` 臂
61
+ (`test/config-provider.test.ts:473` 与 `:509` 各钉一次 `"rosters.0.models.0"`,该臂本批不变)⇒
62
+ **提货不会红**,但那段串是运维面,值得知道它对那两个域变长了一段。
63
+ - **什么补偿** = 闭集给的是**编译期 + 运行期**双可用的替代品(常量 + 判别式),不是「文档里说一声」;
64
+ 值形改动给可 grep 的旧形特征 + 逐处坐标 + 一行改法。
65
+
66
+ ### ① 域闭集发成运行期值(additive,不 breaking)
67
+
68
+ `./cross-domain` 新增两个导出,`RefCheckedDomain` 改为**从值派生**:
69
+
70
+ - `REF_CHECKED_DOMAINS`:六员闭集常量,`as const satisfies readonly (keyof DomainConfig)[]` —— 每一员都
71
+ 被编译期钉成真域键。
72
+ - `type RefCheckedDomain = (typeof REF_CHECKED_DOMAINS)[number]`:同一个集合**只写一处**;此前它是常量
73
+ 旁边的一条手写联合,两份可以各说各话。
74
+ - `isRefCheckedDomain(d: unknown): d is RefCheckedDomain`:运行期判别式(与 `isDomainName` 同款),
75
+ 消费方据此**删掉自己那份手抄清单**。
76
+
77
+ `refIntegrityIssues` 尾部那道穷举守卫(`const unhandled: never = domain`)原样保留,并复核仍咬得住:
78
+ 闭集加第七员而不加分派支 ⇒ `tsc` 报 `error TS2322: Type '"<新域>"' is not assignable to type 'never'`。
79
+
80
+ ### ② `RefIssue.path` 六臂统一(BREAKING,值形)
81
+
82
+ `refIntegrityIssues` 的头注一直写着「Paths start with the domain key」,而其中两臂**不是**:
83
+
84
+ | 域 | 4.0.0 之前 | 4.0.0 |
85
+ | --- | --- | --- |
86
+ | `rosters` / `workers` / `models` / `systems` | `["rosters", …]` / `["workers", …]` / `["models", …]` / `["systems", …]` | 不变 |
87
+ | `collab` | `["templates", i, "slots", j, "members", k, "modelRef"]` | `["collab", "templates", i, …]` |
88
+ | `entitlement` | `["tiers", i, …]`(`roster` / `models` / `visibleSkills` / `visibleMcp` / `visiblePlugins` 五处) | `["entitlement", "tiers", i, …]` |
89
+
90
+ 于是 `path[0]` **恒等于**被校验的那个域,那句头注第一次成为真话。
91
+
92
+ **迁移说明(管理台)**:
93
+
94
+ 1. **批量写腿:本批把它从「两源两形」修回一致,不需要改。** `src/lib/config/apply-batch.ts` 对 zod 问题
95
+ 拼的是 `[index, c.domain, ...iss.path]`(:63),对引用完整性问题拼的是 `[index, ...ri.path]`(:83)——
96
+ 同一个 409 响应里两种坐标空间,而缺域键的恰好是后者。4.0.0 起两者逐字同形。
97
+ 2. **单域 PUT → UI:逐路亲读的结论是不用改。** `src/lib/config/domain-write.ts:108` 把 issues 原样回给
98
+ 路由,UI 侧有两种渲染:① `src/components/domains/issue-map.ts:42` 的字段级定位用
99
+ `path[0] === entriesKey` —— 但它只经 `DESCRIPTORS`(`src/components/domains/descriptors.tsx:419`)服务
100
+ **五个**域(`models` / `mcp` / `skills` / `rosters` / `plugins`),其中受引用完整性校验的 `models` 与
101
+ `rosters` 的域键**恰好等于**其条目键,`collab` / `entitlement` 根本不走这条路;② `collab` /
102
+ `entitlement` 的页面(`entitlement-page` / `tiers-page` / `generic-domain-editor`)只渲染
103
+ `issue.message` 或整条 JSON dump,不读 `path[0]`。**所以 UI 没有字段定位降级。**
104
+ (若日后把 `collab` / `entitlement` 接进 ① 那条路:在边界处剥掉域键前缀
105
+ `path[0] === domain ? path.slice(1) : path` 即与该域 zod 问题的坐标空间一致 —— zod 的 `superRefine`
106
+ 仍从条目键起手。)
107
+ 3. **顺带提一句(本批不动别仓)**:管理台自己那份反向引用告警 `danglingRefWarnings`
108
+ (`src/lib/config/cross-domain.ts`)的 `DanglingRef.path` 仍从 `["tiers", …]` / `["rosters", …]` 起手。
109
+ 它是管理台的面,属主在管理台;要不要跟着统一由它自己定。
110
+ 4. **五处手抄闭集请改调 `REF_CHECKED_DOMAINS`**(这才是那个少一员漏闸的正解,而不是把五员补成六员):
111
+ 管理台 `src/app/api/config/rollback/route.ts:18`、`src/app/api/v1/scopes/[scope]/rollback/route.ts:89`
112
+ (各少 `entitlement` ⇒ 该域回滚漏闸)、`src/lib/config/apply-batch.ts:81` 与
113
+ `src/lib/config/domain-write.ts:107` 的六员 `if` 串(齐全,但仍是手抄);worker 服务
114
+ `src/config-provider.ts:271` 的六员 `for (const d of [...] as const)`(齐全,同上)。
115
+
116
+ **旧形的可 grep 特征**(在消费方仓里跑,命中即是需要人看一眼的点):
117
+
118
+ ```bash
119
+ # a. 把引用完整性坐标当「从条目键起手」用的地方(旧形假设)
120
+ grep -rn '"templates"\|"tiers"' --include='*.ts' --include='*.tsx' src | grep -i 'path\|issue'
121
+ # b. 自己给坐标补域键前缀的地方 —— 4.0.0 后会补成两遍
122
+ grep -rn '\.\.\.ri\.path\|\.\.\.issue\.path\|\.\.\.i\.path\|path\.unshift' --include='*.ts' src
123
+ # c. 手抄的域闭集(五员或六员,Set 或行内联合)—— 改调 REF_CHECKED_DOMAINS
124
+ grep -rn '"rosters".*"workers".*"models"' --include='*.ts' src
125
+ ```
126
+
127
+ ### ③ 两道机器门(`test/ref-domain-closed-set-4.0.0.test.ts`,16 格)
128
+
129
+ - **闭集门**:`REF_CHECKED_DOMAINS` 与 `refIntegrityIssues` **真实分派**到的域集合逐字相等(双向:少一支
130
+ = 某域声明要查却没人查;多一支 = 闭集没登记的域被偷偷查了)。分派集合从**源码 AST 机器解出**
131
+ (形参名也从 AST 读),门里**不留第二份域名清单** —— 手抄清单正是本批要治的病,不能把它搬进门里。
132
+ 另钉「一域一支」(两支同域 / 一支 `||` 串两域,都是那道穷举守卫咬不住的形态)与「解不出分派形就判红」
133
+ (fail-closed:分派改成第三种结构时门必须吵,不许静默放行)。三条变异自证它有牙。
134
+ - **path 形门**:六个域各造一条**真悬空引用**(五个走真 `parseDomain`;`models` 臂用未过 schema 的裸值
135
+ ——`ModelsConfig` 自己的 `superRefine` 就拒悬空自引用,过了 parse 的 models 值在结构上造不出这条),
136
+ 断言每条 issue 的 `path[0]` ∈ `REF_CHECKED_DOMAINS` 且 == 被查的域键,并先钉「反例真的红了」防假绿。
137
+ 反例表的形是 `satisfies Record<RefCheckedDomain, …>`:闭集加一员而没补反例 ⇒ **编译期**红。
138
+ **红先读数**(本节 ② 落地前、① 已在):16 格里 4 格红 —— `collab` 臂
139
+ (`path[0]="templates" 不在 REF_CHECKED_DOMAINS 里`)、`entitlement` 臂(`path[0]="tiers"`)、六臂合集
140
+ (得到 `templates` / `tiers`,期望 `collab` / `entitlement`)、坐标细节钉;其余四臂与闭集门 8 格全绿。
141
+
142
+ ### ③ 诊断面 `droppedKeys()` / `carriedUnknownKeys()` 回 `{ keys, total }`(BREAKING,返回形)
143
+
144
+ ```diff
145
+ -export function droppedKeys(domain, raw, parsed): ConfigPath[]
146
+ -export function carriedUnknownKeys(domain, parsed): ConfigPath[]
147
+ +export interface UnknownKeyScan { keys: ConfigPath[]; total: number }
148
+ +export function droppedKeys(domain, raw, parsed): UnknownKeyScan
149
+ +export function carriedUnknownKeys(domain, parsed): UnknownKeyScan
150
+ ```
151
+
152
+ **谁需要**:自装配读腿的消费方(不走 `parseDomainLoud` 的 `onWarning`、自己拿这两只函数做诊断)。此前
153
+ 点名列表的上界(`DROP_SCAN_MAX_KEYS`)与真实总数**都是私有的** ⇒ 它拿到 64 条,既不知道是不是刚好 64 条,
154
+ 也没有第二个公开面可以问「一共多少」,**结构上铸不出** `truncatedFrom`;而本包自己的告警面从 1.5.0 起就带
155
+ 着这个数 —— 同一件事在两个公开面上一个说、一个不说。把有界诊断当穷举决策输入,正是服务端消费方在配置门上
156
+ 三次被复审打穿的根因。
157
+
158
+ **谁受伤**(九个仓逐条命中**逐行亲读**,2026-09-22 —— 不是拿 grep 计数下的结论):
159
+ - **生产码零受伤**:worker 服务 / CLI / 管理台 / client SDK / 桌面 / web 客户端的 `src` 对这两只**包函数**
160
+ 零 import。同名命中逐条判过,没有一条是它:worker 服务 `src/spec-fields.ts:443,468,525` 是它自己留痕事件
161
+ `task_limits_legacy_key_dropped` 的字段名;CLI `src/cli/handlers/cloudResources.ts:617-618` 读的是
162
+ registry PUT 响应里的 wire 字段 `json.droppedKeys`;SDK `packages/sdk/src/control-types.ts:61,68` 同一个
163
+ wire 字段;管理台 `src/lib/config/domain-write.ts:47` 是**它自己另写的一只同名函数**(算的是「本次写入被
164
+ schema 丢掉的键」,本包算的是「读回来时未知被丢的键」——**同名不同物**),其余管理台命中全是那个 wire 字段。
165
+ ⚠️ 请管理台把那只改名(语义是 `strippedKeys`),否则 4.0.0 之后两边的文档会互相污染。
166
+ - **有一个带名字的非生产消费方**:评测线 sema-test 的 ss300 黑盒半场 ——
167
+ `repro/ss300-corpus-20260919/min-reader.mjs:34` 从 dist 解构了这两个名字(名字还在、解构照旧成立),
168
+ `c09-parsedomainloud-probe.mjs:17-20` 真调用它们并**打印返回形**:那两行的读数会从 `array[64]` 变成
169
+ `object`(不抛、不红,是探针的输出变了)。顺带说一句:B3 要补的缺口正是评测线自己量出来的 ——
170
+ 它在 `min-reader.mjs:42-46` 与 `verify.mjs:265-269` 白纸黑字写着「那两个只回数组的导出才是零消费者的
171
+ **半成品**」「读数对(那两个导出确实不给 total)、结论错(整个公开面并非没有出路)」;4.0.0 之后那句
172
+ 「半成品」不再成立,自接装配层的消费方可以直接从这两只函数拿到总数。
173
+
174
+ **补偿**:`total` 就是消费方要的那个数(`total > keys.length` ⇔ 被截断,自铸 `truncatedFrom = total`);
175
+ 上界 `DROP_SCAN_MAX_KEYS` **仍然私有** —— 它是告警载荷的上界,不是契约,改它不该让任何消费方红。
176
+ 顺带把两只私有扫描包装(`scanDropped` / `scanCarried`)删了:公开面自己就是那一次扫描,`parseDomainLoud`
177
+ 铸告警时调的也是它 ⇒ 消费方自铸的数与告警里那个**同一个铸点**。
178
+
179
+ **红先读数**(B3 落地前,跑在本节 ①② 已在的树上,`test/diagnostics-scan-total-4.0.0.test.ts`):
180
+ `Tests 5 failed | 4 passed (9)`。绿的四格 = 三格「改前形」钉(`droppedKeys` 回的就是数组本身、`.total`
181
+ 恒 `undefined`、公开面上没有第二个入口可以问真实总数)+ 一格「上界仍私有」;红的五格里最要紧的那条是
182
+ **消费方视角**的自铸:`expected {} to deeply equal { truncatedFrom: 70 }` —— 70 个未知键、列表截到 64,
183
+ 消费方算不出那个 70。改后「改前形」三格已按新形改写,全文件 6 格绿。
184
+
185
+ ### ④ 告警投影 `WarningProjection` 改**机器派生**,属主搬回 `./config-fns`(BREAKING,模块面)
186
+
187
+ `WARNING_KINDS` / `WarningKind` / `WarningProjection` / `projectWarning` 的属主从 `./conformance` 搬到
188
+ **告警联合自己的模块** `./config-fns`;`./conformance` 只**再导出**,kit 的进口一字不用改
189
+ (`import { projectWarning } from "@sema-agent/settings-schema/conformance"` 照旧可解析、照旧是同一只函数)。
190
+ 投影的**形**从一张手写字段表改成从联合**逐臂派生**:
191
+
192
+ ```ts
193
+ type PerArmOmit<W, K extends string> = W extends unknown ? Omit<W, K> : never; // 裸类型参数 ⇒ 逐员分配
194
+ export type WarningProjection = PerArmOmit<EffectiveReadWarning, "error">;
195
+ ```
196
+
197
+ **病**:手写投影与它投影的联合之间没有任何字段级约束 ⇒ 给已有 kind 加一个字段,投影不动、`projectWarning`
198
+ 不动、跨仓一致性门照绿,新字段一路穿过 kit,两边比的是一份**少一个字段**的告警(而都显示全绿)。
199
+ **注意分配律**:直接写 `Omit<EffectiveReadWarning, "error">` 会先把联合塌成交集再剥,`keys` / `path` /
200
+ `file` 这些只属于某几臂的字段会整片消失 —— 那是这条派生唯一的坑。
201
+
202
+ **消费方影响**:派生出来的形比手写那份**更紧**(`domain: string` → `DomainName`;`why: string` →
203
+ `"not-a-domain" | "non-portable-domain"`;`keys` / `droppedNames` 从 `readonly` 数组变可变数组)。本仓内
204
+ 零处需要改;kit 的运行期行为一字未变(语料、报告、比对键全同形;`./conformance` 与 `./config-fns` 两条路
205
+ 取到的 `projectWarning` 是**同一只函数**,实测 `===`)。**顺带一个正效果**:属主在 `./config-fns` ⇒ 投影也
206
+ 出现在**主 barrel**(浏览器安全那一半),消费方不必为一只投影函数去装 Node-only 的 kit。
207
+
208
+ > 🔴 **订正一句抽象盘点稿里的话**(本批亲读,2026-09-22):盘点稿说管理台
209
+ > `src/lib/config/effective-warnings.ts` 的 `warningFacts` 是「`projectWarning` 的另一份实现」——**不是**。
210
+ > 逐行读它(`:78-99`):它出的是**管理面 wire 形** —— 坐标 `names(w.keys)` / `name(w.path)` 已经 stringify
211
+ > 成串、`error` 换成 `REDACTED_WIRE_ERROR` 占位、真 `error` 另路返回给服务端日志;而 `projectWarning` 保
212
+ > **结构化段坐标**、`error` 整个剔掉。两者共用的只是「逐 kind 列字段 + 一个 `never` 穷举 switch」这个骨架。
213
+ > 所以它**不能**直接改调本包的投影(那会丢掉 stringify 与脱敏两层);正解是**架在**派生投影之上(投影出门
214
+ > 后再过一遍坐标 stringify 与 error 脱敏),那样它的字段枚举才跟着契约走。这条留给管理台自己定。
215
+
216
+ ### ⑤ prompts 摘要搬出调度存储模块,两个 sha256 铸点合成一只(不 breaking)
217
+
218
+ `promptsContentDigest` / `sectionContentHash` 从 `src/scheduler-store-node.ts` 搬进新模块
219
+ `src/prompts-digest.ts`,**仍挂在 `./node` 这条 subpath 上** —— 消费方的 import 一个字不用改
220
+ (管理台 `src/lib/config/resolve-prompts.ts:17` 与 `test/resolve-prompts.test.ts:8` 真在用
221
+ `@sema-agent/settings-schema/node`;逐行亲读 2026-09-22)。原处**删干净**、不留转出、零别名。
222
+
223
+ 两条理由:①它们与调度存储(`scheduled_tasks.json` 的原子读写 + 跨进程变更原语)零关系;②搬家顺手把
224
+ **两个** sha256 铸点合成一只 —— 此前整包 digest 与逐 section contentHash 各 `createHash('sha256')` 一次、
225
+ 各拼一次 `sha256:` 前缀,而这是跨仓对账锚(center 产、server 透传、core manifest 归因三方独立复算),
226
+ 哪天有人只改一处就成了两个刻度,**而没有一格会红**。摘要改用本包**纯实现**的 `sha256Hex`(与
227
+ `createHash("sha256")` 逐字节相同,`test/sha256.test.ts` 逐例压着)⇒ 整个包的 sha256 只剩**一份实现**。
228
+ **值一个字节没变**:`test/prompts-digest-4.0.0.test.ts` 拿 `node:crypto` 当参照逐例压 `sectionContentHash`
229
+ 与 `contentDigest` / `packId` / `sectionHashes`。
230
+
231
+ ### ⑥ `fileSchedulerStore` → `createFileSchedulerStore`(BREAKING,重命名,零别名)
232
+
233
+ `./node` 的 `fileSchedulerStore(path)` 返回一只带 `load` / `save` 的**活对象**,按本组工厂命名法该是
234
+ `create*`;`2.0.0` 那次照人眼改,只改到了同族的 `siblingResolver`,这处漏了 —— 人眼扫一遍的门,漏掉的
235
+ 那个永远不会自己红,所以这一版连同**门④** 一起落地(以后由机器看着)。
236
+
237
+ **零别名、零双读、零过渡方法**:旧名**已删**。消费方的 `tsc` 红(具名 import 找不到导出)或运行期
238
+ `undefined` 就是通知本身。迁移 = 把 `fileSchedulerStore(` 一律改成 `createFileSchedulerStore(`;
239
+ `loadSchedulerStore` / `saveSchedulerStore` / `mutateSchedulerStore` 三只**一字未动**。
240
+ 红先读数:改名前放一格引用旧名的临时文件 ⇒ `tsc` rc=0(探针今天编译得过);改名后同一格 ⇒
241
+ `error TS2724: '"../src/scheduler-store-node.js"' has no exported member named 'fileSchedulerStore'`(rc=2)。
242
+
243
+ `materializeLocalRoot` **不改名**:见门④ 那条射程裁定 —— 它返回 `{ root, cleanup }`,属**资源获取**族。
244
+
245
+ ### ⑦ 一致性门的来源律改**运行期拒**(fail-closed)
246
+
247
+ `createContractAdapter` 的头注一直自陈「它不是给消费方抄的」,而那句话**机器证不了**:把契约包自己的
248
+ 函数注册成读腿,门证明的只是「包等于包」,而且看起来全绿。4.0.0 起这句自陈**有牙**:
249
+
250
+ - **判别式单铸**在 `src/conformance/entry-origin.ts`,两形闭集 —— `own-build-output`(落在**正在跑这段码
251
+ 的那份**契约包的 `<包根>/dist/` 下 = 拿自己比自己)与 `another-installed-copy`(落在**另一份**装好的
252
+ 契约包里)。**先判「我自己」**:消费方装好的那份 kit 自己就住在 `node_modules` 里,它的 dist 同时命中
253
+ 两形,顺序反了最该拒的那一形反而被放行(本批自查出的承重位,已钉一格)。咬 `dist/` 而不是整个包根,
254
+ 是因为契约仓**自证**时腿的入口是本仓 `src/*.ts`,而 `src/` 不随包发。
255
+ - **两处执法,同一只判别式,哪一形归哪一处有判据**:工厂看不见 `mode`,所以它只拒**在任何模式下都错**的
256
+ 那一形(`own-build-output`)—— **当场拒**(抛,不是告警:一条告警在 CI 日志里活不过第二天,而这道门一旦
257
+ 被同义反复接进去,它报的全绿是**假绿**);`another-installed-copy` 在 `historical` 下是**正当**的(那个
258
+ 模式就是拿旧版包比行为,而旧版包常常正好装在 `node_modules/<包名>/` 布局下 —— codex 对抗复审 [medium]
259
+ 点出这条,成立并已改),归看得见 mode 的 `release` 门。手写的适配器字面量(接入文档的默认配方)走不到
260
+ 工厂 ⇒ 那道门**两形都判**。**拒绝句与违例句同源**,都说清「你落在哪儿 / 合法的是哪儿」;消费方那侧靠
261
+ `sits inside the contract package` 这段文案点名的钉子**一字未动**。
262
+ - **判据咬的是文件,不是字符串**:判别式两侧都先归一(`resolve` 抹平点段 / 双斜杠,再尽力 `realpath` 解一次
263
+ 软链 —— `npm link` 出来的树就是软链)。`release` 门本来就先解软链再判,但 `historical` 模式根本不跑那道门,
264
+ 工厂这一侧是那条路上**唯一的闸** ⇒ 归一必须做在**判别式里**,两处执法才咬同一个判据(codex 对抗复审
265
+ 第二轮 [medium] 点出 `<包根>/./dist/x.js` 能绕过,成立并已改;顺带把 `run.ts` 里那次重复的解软链删掉)。
266
+ - **对照臂(不许误伤)**:消费方自己树里的生产入口(评测线自写的最小 reader 就是这形)、契约仓自证的
267
+ `src/*.ts`、historical 模式的旧版包(解包的、或装在 `node_modules` 布局下的)—— 一条都不拒,
268
+ 红先的两次读数里它们恒绿。
269
+ - 红先读数:把拒关掉 ⇒ `Tests 5 failed | 9 passed`(工厂三臂 + 缺字段臂 + 门那一臂全红,对照臂不动);
270
+ 装回来 ⇒ 全绿。归一那一条也是红先落的:先写点段 / 双斜杠 / 软链三格 ⇒ `1 failed`(`expected [Function]
271
+ to throw`),归一进判别式后同格转绿,`来源律` 整组 17 格全绿。
272
+
273
+ ### ⑧ `./api/auth` 取「迁移」不取「删」
274
+
275
+ 整面**没有任何消费仓 import 子路径 `./api/auth`**,也没有人从本包桶里取这 18 个名(尺子:同一把 grep 数
276
+ 各仓的 `@sema-agent/settings-schema` import 行,读数几十到上百 —— 零是真零)。**但零消费不等于这面死了**,
277
+ 它是被**各抄了一份**:管理台 `src/lib/auth/device-flow.ts:57-63` 的 `normalizeUserCode` 与本包
278
+ `src/api/auth.ts:89-95` **逐字节相同**(diff 实测),另有自己的 `DEVICE_GRANT_TYPE`(`:35`)/
279
+ `DEVICE_POLL_INTERVAL_SECONDS`(`:31`);壳 `src/sema/cloudAuth.ts:41` 自声明 `DeviceCodeResponse`
280
+ (与本包同名不同物)、`:50` 自声明 `TokenResponse`;SDK 在 `packages/sdk/src/registry/auth.ts:16` 对路径表与
281
+ grant_type 做**值拷贝**,并在**生产码** `:11` 从**改名前的旧包名**把 `DeviceCodeResponse` /
282
+ `TokenGrantResponse` 两只当类型 import,靠自己的同源锚测试(`test/registry-auth.test.ts:26,28`)对账 ——
283
+ 而那道锚钉的是旧包(devDependency 停在 `0.17.1`)⇒ 本包改名之后的任何改动它一格也看不见。
284
+ (「零 import」那句话的**精确射程**:没有任何仓从 `@sema-agent/settings-schema` 这个名字下取这 18 个名;
285
+ 从**旧包名**取的有 SDK 那 3 行。)
286
+
287
+ ⇒ 删掉等于把三份手抄就地合法化。凭据轴该有单属主,所以本版**保留该出口**,理由 / 谁该改调 / 什么条件下删
288
+ 逐字写进 `src/api/auth.ts` 的文件头注。**下一个 major** 若三方都已改调(或各自明确表态不改并认账)且那时
289
+ 仍是零消费,「删」的理由才成立。
290
+
291
+ ### 两道随本批落地的机器门(门③ / 门④)+ 一格闭集补全
292
+
293
+ - **门③ 投影派生门**(`test/warning-projection-derived-4.0.0.test.ts`):从 AST 取 `EffectiveReadWarning`
294
+ 的**每一臂声明**(不是字段清单 —— 锚在手抄清单上,真键一加门就断),各插一个必填字段,用
295
+ LanguageService 重编译整棵 `src`,断言诊断落在 `projectWarning` 的**函数段内**(段也是 AST 取的)。
296
+ **两个读数**:
297
+ · **改前**(投影还是手写的那棵树):七臂变异,`projectWarning` 段内诊断恒 **0** ——「今天确实不红」
298
+ 是实测,不是读码推演;同一次变异在**段外**有 1–2 条(`buildEffective` / `parseDomainLoud` /
299
+ `salvageEntries` / `file-store` 这些**铸造点**少铸一个必填字段),与投影一格关系也没有 ⇒ 门只认段内。
300
+ 整文件读数 `Tests 7 failed | 3 passed (10)`。
301
+ · **改后**:七臂变异,段内诊断各 ≥1(逐臂都红),整文件 `Tests 10 passed (10)`。
302
+ 另两格是承重的:未变异时全 `src` 零诊断(基线不红,否则「必红」是假读数)、变异还原后仍零诊断。
303
+ - **闭集补全格**(同文件):`WARNING_KINDS` == 从 AST 解出的联合臂集(逐字同集同序,双向)。**为什么要它**:
304
+ 这张表的旧注写着「新 kind 不登记 ⇒ `satisfies` 编译红」,**实测是假的** —— 给联合加一条新臂、不登记进表,
305
+ `satisfies` 那行零诊断,红的是三处 `never` 穷举臂(`projectWarning` / `rebaseWarningPaths` / kit 的
306
+ `normalizeProjection` 各一条)。`satisfies` 只咬「多一个联合里没有的词」,咬不住「少登记一员」;
307
+ 那半条缝现在由这一格咬。带负控(拿一张故意少一员/多一员的表喂比对函数,必须报差 —— 否则就是
308
+ 「两边都从同一处来、永远相等」的假绿)。
309
+ - **门④ 工厂命名门**(`test/factory-naming-4.0.0.test.ts`):从**类型**判「活对象」——返回类型剥掉
310
+ `Promise`/`undefined`/`null` 后,自己可调用、或逐层下探(≤3 层)摸得到一个可调用的属性,**且那个调用
311
+ 签名是本仓声明的**(`string[]` 的 `.map`、zod 的方法都声明在 lib / node_modules 里,数组与第三方值不该
312
+ 因为「有方法」被判成活对象)。
313
+ **射程裁定(发布方)**:命名法只管**工厂**,**不管**返回「一份必须释放的把手」的**资源获取**族。于是门是
314
+ **一只判别式分两族,每族一条律**(闭集 —— 每只返回活对象的导出函数恰好落在一族里):
315
+ · **工厂族**(返回活对象、返回形第一层**没有**释放把手)⇒ 名字必须 `create*`;
316
+ · **资源获取族**(返回形第一层有 `cleanup` / `dispose` / `close` / `release` / `Symbol.[async]dispose`
317
+ 这么一只**本仓声明的可调用**属性)⇒ 名字必须 `acquire*` / `open*` / `materialize*`。
318
+ 第二条是第一条的**反向咬合**:随手挂一只 `cleanup: () => {}` 躲开 `create*` 的路在这里堵死 ——
319
+ **门按类写,不做逐名白名单**(白名单会把「今天这两个名字」冻成律,下一个同类还是漏)。本仓当下读数:
320
+ 125 只导出函数 = 工厂族 3(`createContractAdapter` / `createSiblingResolver` / `createFileSchedulerStore`)
321
+ + 资源获取族 1(`materializeLocalRoot`)+ 纯数据 121;**两族违例各 0**,已知违例登记表随之**删空**。
322
+ 判别力自证**两条,各咬一族**:① 把 `createContractAdapter` 临时改名成 `makeContractAdapter`
323
+ (AST 取声明位、内存覆盖、不落盘)⇒ 立刻进工厂族违例名单;② 把 `materializeLocalRoot` 返回形里的
324
+ **释放把手改个名**(函数名一个字不动)⇒ 它掉回工厂族并被判违例(证明豁免咬的是**返回形**不是名字)。
325
+ **反向那一半(`create*` 必须返回活对象)今天是绿的,但刻意没立门** —— 一个返回 `void` 的过程名会被判成
326
+ 假红,那条判据要先说清「工厂」与「过程」怎么分。**已知会翻红的反例(诚实登记,不预先豁免)**:哪天出现
327
+ 一只真·工厂、它造的活对象自己带 `close()`(例:连接池客户端),门会按第二条要求它改名 —— 那时候要的是
328
+ 给命名法补一句「资源 vs 客户端」怎么分的裁定,而不是在门里加一行特例。
329
+
330
+ ### 本版**没做**的(免得读成「抽象批已全收」)
331
+
332
+ 同一轮抽象盘点开出九件,本版收了八件(上面 ①–⑧ 与四道门);**剩下一件未动**,按它自己的批次发:
333
+
334
+ 1. cross-session 两张词表与引擎包**双属主**:本包这 10 名生产面零痕迹,但消费方设了锁步门。候属主表态,
335
+ 认领后随下一个 major 删。
336
+
337
+ 另有两件**只记不做**(理由在各自小节里):`./api/auth` 的「删」分支要等下一个 major 才有理由(见 ⑧);
338
+ `./types` 那批零痕迹名与门面转出名未逐条判「预留 / 腐烂」,本版不以它们为据删任何东西。
339
+
9
340
  ## 3.0.1 — 2026-09-22 — 纯文档 patch:订正幽灵版号、重写消费接入档到 3.0.0、README 真话、待裁账并入 ROADMAP、产品名统一、一致性门新增 4 条对抗语料
10
341
 
11
342
  **零行为改动、零类型改动、零 wire 面变化。**
package/README.md CHANGED
@@ -51,7 +51,9 @@ sound). `npm ls --prod` therefore shows only `zod`. Node built-ins only (`node:c
51
51
  shapes (`User`, `PublishedSnapshot`, …).
52
52
  - **Roster resolution** (`resolve-roster.ts`): `resolveEffectiveForWorker` → the per-worker wire payload.
53
53
  - **Ref integrity** (`cross-domain.ts`): `refIntegrityIssues` + `createSiblingResolver` (the pure half — fleet
54
- auto-placement stays in the config-center 产品).
54
+ auto-placement stays in the config-center 产品). The ref-checked domains are a RUNTIME closed set —
55
+ `REF_CHECKED_DOMAINS` + `isRefCheckedDomain` (`RefCheckedDomain` is derived from the value) — so a consumer
56
+ asks the contract instead of hand-copying the list; every `RefIssue.path` starts with the domain key.
55
57
  - **Hashing** (`hash.ts`): `stableHash` (FNV-1a 64) for ETags / version detection.
56
58
  - **Skills manifest** (`skills-manifest.ts`): content-addressing for the lazy-pull skill bodies.
57
59
  - **Secret inventory** (`secret-refs.ts`): `collectEnvRefs` — every referenced env-NAME.
@@ -162,7 +164,9 @@ if (!report.ok) throw new Error(report.violations.join("\n"));
162
164
  ```
163
165
 
164
166
  - **适配器**带 `provenance`(你**实际加载到**的那份契约包)与每条腿的 `entry{module,export}`
165
- (**生产代码**的入口 —— 在测试里把契约包函数本身当读腿,门证明的只是「包等于包」)。
167
+ (**生产代码**的入口 —— 在测试里把契约包函数本身当读腿,门证明的只是「包等于包」)。4.0.0 起这句
168
+ **有牙**:入口落在**本包自己的 `dist/`**(拿自己比自己)⇒ `createContractAdapter` **当场拒**;落在
169
+ **另一份**装好的契约包里(`historical` 模式的正当目标)⇒ 工厂不拒,由 `release` 门点名。
166
170
  - **两种模式**:`release`(版本三方对齐 + 生产入口核验 + 禁 `only`/归一器 + 至少登记一条腿 + **登记的腿
167
171
  必须跑**;未登记的腿只记 `skipped: no adapter`,不算违例)与 `historical`(对旧版包跑,只记行为 diff)。
168
172
  - **四律**:对抗律 / 变异律 / 覆盖律 / 来源律 —— 缺一不算「门」。
@@ -1 +1 @@
1
- 3.0.1
1
+ 5.0.0
@@ -24,6 +24,27 @@
24
24
  * RS256 JWT (1h) — see `api/auth-bridge` for how a worker verifies it.
25
25
  *
26
26
  * Pure zod contract — zero IO, browser-safe, same rules as the rest of this package.
27
+ *
28
+ * ── 4.0.0:这一面**保留**(不删)。理由 / 谁该改调 / 什么条件下删 ───────────────────────────────
29
+ *
30
+ * 读数(4.0.0 前夜逐行核过,不是按计数下的结论):**没有任何消费仓 import 子路径 `./api/auth`**,
31
+ * 也没有人从本包的桶里取这 18 个名(尺子:同一把 grep 在那几个仓里数 `@sema-agent/settings-schema`
32
+ * 的 import 行,读数分别是几十到上百行 —— 所以那个零是真零,不是 grep 坏了)。**但零消费不等于这面死了**:
33
+ * 上面第 2-4 行那句「大家 import ONE source of truth」今天是**意图**,不是读数 —— 真实情况是各抄了一份:
34
+ * · 管理面(配置控制台)`src/lib/auth/device-flow.ts` 自带一只 `normalizeUserCode`,函数体与本文件那只
35
+ * **逐字节相同**,另有自己的 `DEVICE_GRANT_TYPE` / `DEVICE_POLL_INTERVAL_SECONDS`;
36
+ * · 壳(命令行)`src/sema/cloudAuth.ts` 自声明 `DeviceCodeResponse` / `TokenResponse` 两只响应形;
37
+ * · SDK 在自己的 `registry/auth.ts` 里做**值拷贝**(`REGISTRY_AUTH_PATHS` / grant_type / 错误码集),
38
+ * 并在**生产码**里把 `DeviceCodeResponse` / `TokenGrantResponse` 两只当类型 import —— 但取的是
39
+ * **改名前的那个旧包名**,靠它自己的「同源锚」测试对账;那道锚今天钉的是旧包(devDependency 停在
40
+ * 0.17.1),于是本包改名之后的任何改动它都看不见。
41
+ * (所以上面那句「零 import」的**精确射程**是:没有任何仓从**本包这个名字**下取这 18 个名。)
42
+ *
43
+ * ⇒ 删掉这一面 = 把上面三份手抄就地合法化。凭据轴该有**单属主**,所以 4.0.0 取「迁移」不取「删」:
44
+ * · 谁该改调:管理面与壳直接 import 本面(先从 `normalizeUserCode` / 设备码响应形这几只开始);
45
+ * SDK 把它那道同源锚从旧包名改到本包(锚不动,漂移就永远是绿的)。
46
+ * · 什么条件下删:**下一个 major**,且届时三方都已改调(或各自明确表态不改并自己认账),且那时**仍**
47
+ * 是零消费 —— 那天「删」的理由才成立。今天不成立:今天的零是手抄造出来的,不是这面没人要。
27
48
  */
28
49
  import { z } from "zod";
29
50
  /** The five auth endpoints, relative to the registry origin. */
@@ -100,8 +121,9 @@ export declare const DeviceCodeResponse: z.ZodObject<{
100
121
  user_code: z.ZodString;
101
122
  /** The /activate page the user opens (any device, any logged-in browser). */
102
123
  verification_uri: z.ZodString;
103
- /** verification_uri with ?user_code= pre-filled — one-click / QR path. */
104
- verification_uri_complete: z.ZodString;
124
+ /** verification_uri with ?user_code= pre-filled — one-click / QR path. **OPTIONAL** (RFC 8628 §3.2):
125
+ * absent ⇒ the reader shows `verification_uri` + `user_code`; present-but-empty is still refused. (5.0.0, S-615) */
126
+ verification_uri_complete: z.ZodOptional<z.ZodString>;
105
127
  /** Seconds until the handshake expires (DEVICE_CODE_TTL_SECONDS). */
106
128
  expires_in: z.ZodNumber;
107
129
  /** Minimum seconds between polls (DEVICE_POLL_INTERVAL_SECONDS). */
@@ -113,8 +135,9 @@ export declare const DeviceCodeResponse: z.ZodObject<{
113
135
  user_code: z.ZodString;
114
136
  /** The /activate page the user opens (any device, any logged-in browser). */
115
137
  verification_uri: z.ZodString;
116
- /** verification_uri with ?user_code= pre-filled — one-click / QR path. */
117
- verification_uri_complete: z.ZodString;
138
+ /** verification_uri with ?user_code= pre-filled — one-click / QR path. **OPTIONAL** (RFC 8628 §3.2):
139
+ * absent ⇒ the reader shows `verification_uri` + `user_code`; present-but-empty is still refused. (5.0.0, S-615) */
140
+ verification_uri_complete: z.ZodOptional<z.ZodString>;
118
141
  /** Seconds until the handshake expires (DEVICE_CODE_TTL_SECONDS). */
119
142
  expires_in: z.ZodNumber;
120
143
  /** Minimum seconds between polls (DEVICE_POLL_INTERVAL_SECONDS). */
@@ -126,8 +149,9 @@ export declare const DeviceCodeResponse: z.ZodObject<{
126
149
  user_code: z.ZodString;
127
150
  /** The /activate page the user opens (any device, any logged-in browser). */
128
151
  verification_uri: z.ZodString;
129
- /** verification_uri with ?user_code= pre-filled — one-click / QR path. */
130
- verification_uri_complete: z.ZodString;
152
+ /** verification_uri with ?user_code= pre-filled — one-click / QR path. **OPTIONAL** (RFC 8628 §3.2):
153
+ * absent ⇒ the reader shows `verification_uri` + `user_code`; present-but-empty is still refused. (5.0.0, S-615) */
154
+ verification_uri_complete: z.ZodOptional<z.ZodString>;
131
155
  /** Seconds until the handshake expires (DEVICE_CODE_TTL_SECONDS). */
132
156
  expires_in: z.ZodNumber;
133
157
  /** Minimum seconds between polls (DEVICE_POLL_INTERVAL_SECONDS). */
package/dist/api/auth.js CHANGED
@@ -24,6 +24,27 @@
24
24
  * RS256 JWT (1h) — see `api/auth-bridge` for how a worker verifies it.
25
25
  *
26
26
  * Pure zod contract — zero IO, browser-safe, same rules as the rest of this package.
27
+ *
28
+ * ── 4.0.0:这一面**保留**(不删)。理由 / 谁该改调 / 什么条件下删 ───────────────────────────────
29
+ *
30
+ * 读数(4.0.0 前夜逐行核过,不是按计数下的结论):**没有任何消费仓 import 子路径 `./api/auth`**,
31
+ * 也没有人从本包的桶里取这 18 个名(尺子:同一把 grep 在那几个仓里数 `@sema-agent/settings-schema`
32
+ * 的 import 行,读数分别是几十到上百行 —— 所以那个零是真零,不是 grep 坏了)。**但零消费不等于这面死了**:
33
+ * 上面第 2-4 行那句「大家 import ONE source of truth」今天是**意图**,不是读数 —— 真实情况是各抄了一份:
34
+ * · 管理面(配置控制台)`src/lib/auth/device-flow.ts` 自带一只 `normalizeUserCode`,函数体与本文件那只
35
+ * **逐字节相同**,另有自己的 `DEVICE_GRANT_TYPE` / `DEVICE_POLL_INTERVAL_SECONDS`;
36
+ * · 壳(命令行)`src/sema/cloudAuth.ts` 自声明 `DeviceCodeResponse` / `TokenResponse` 两只响应形;
37
+ * · SDK 在自己的 `registry/auth.ts` 里做**值拷贝**(`REGISTRY_AUTH_PATHS` / grant_type / 错误码集),
38
+ * 并在**生产码**里把 `DeviceCodeResponse` / `TokenGrantResponse` 两只当类型 import —— 但取的是
39
+ * **改名前的那个旧包名**,靠它自己的「同源锚」测试对账;那道锚今天钉的是旧包(devDependency 停在
40
+ * 0.17.1),于是本包改名之后的任何改动它都看不见。
41
+ * (所以上面那句「零 import」的**精确射程**是:没有任何仓从**本包这个名字**下取这 18 个名。)
42
+ *
43
+ * ⇒ 删掉这一面 = 把上面三份手抄就地合法化。凭据轴该有**单属主**,所以 4.0.0 取「迁移」不取「删」:
44
+ * · 谁该改调:管理面与壳直接 import 本面(先从 `normalizeUserCode` / 设备码响应形这几只开始);
45
+ * SDK 把它那道同源锚从旧包名改到本包(锚不动,漂移就永远是绿的)。
46
+ * · 什么条件下删:**下一个 major**,且届时三方都已改调(或各自明确表态不改并自己认账),且那时**仍**
47
+ * 是零消费 —— 那天「删」的理由才成立。今天不成立:今天的零是手抄造出来的,不是这面没人要。
27
48
  */
28
49
  import { z } from "zod";
29
50
  // ── endpoint paths + protocol constants (shipped values) ─────────────────────────────────────────────────
@@ -125,8 +146,9 @@ export const DeviceCodeResponse = z
125
146
  user_code: z.string().min(1),
126
147
  /** The /activate page the user opens (any device, any logged-in browser). */
127
148
  verification_uri: z.string().min(1),
128
- /** verification_uri with ?user_code= pre-filled — one-click / QR path. */
129
- verification_uri_complete: z.string().min(1),
149
+ /** verification_uri with ?user_code= pre-filled — one-click / QR path. **OPTIONAL** (RFC 8628 §3.2):
150
+ * absent ⇒ the reader shows `verification_uri` + `user_code`; present-but-empty is still refused. (5.0.0, S-615) */
151
+ verification_uri_complete: z.string().min(1).optional(),
130
152
  /** Seconds until the handshake expires (DEVICE_CODE_TTL_SECONDS). */
131
153
  expires_in: z.number().int().positive(),
132
154
  /** Minimum seconds between polls (DEVICE_POLL_INTERVAL_SECONDS). */
@@ -196,7 +196,7 @@ export interface EntrySalvageResult {
196
196
  * 一条粘错位置的凭据,所以路径里的键段过 {@link redactSecrets} 才出门(不变量①:secret 值永不随错误外流,
197
197
  * 与 parseDomain 的 R13/R14 同一条);② 整段遍历包 try/catch —— 读字段、枚举 Record 都会触发访问器,
198
198
  * 抛出来的文案可能带 secret。抢救是**尽力而为**的:抢不动就当没抢救(返回原引用),让既有的整域档位去裁,
199
- * 绝不把解析拖下水、更不回显它的错误(与同文件 `scanDropped` 逐字同纪律)。
199
+ * 绝不把解析拖下水、更不回显它的错误(与同文件 {@link droppedKeys} 逐字同纪律)。
200
200
  *
201
201
  * 边界(如实登记):① 只看**顶层**字段(条目集合就在顶层,嵌套的坏形属于条目自身的问题);② raw 不是
202
202
  * 纯 JSON 对象、字段值与 schema 形状对不上(schema 说数组、文档给对象)⇒ 原样返回,交给整域档位裁;
@@ -296,6 +296,46 @@ export type EffectiveReadWarning = {
296
296
  domain?: DomainName;
297
297
  why: "not-a-domain" | "non-portable-domain";
298
298
  };
299
+ /** {@link EffectiveReadWarning} 的 `kind` **闭集**(运行期值)。一致性 kit 的覆盖律按它计数,消费方要遍历
300
+ * 「所有告警种类」也用它 —— 属主在告警联合自己的模块里,`./conformance` 只再导出(4.0.0 前它住在 kit 里,
301
+ * 那边是联合的下游,却握着联合的闭集)。
302
+ *
303
+ * **这张表由谁咬住**(实测,2026-09-22,别照 `satisfies` 的样子推):`satisfies` 只咬「多一个联合里**没有**
304
+ * 的词」;**少登记一个** kind 它咬不住(少一员的数组照样可赋值)。真正咬「新 kind 必须被处置」的是三处
305
+ * `never` 穷举臂 —— {@link projectWarning}、{@link rebaseWarningPaths}、kit 的 `normalizeProjection`
306
+ * (给联合加一条新臂,实测这三处各红一条,`satisfies` 那行零诊断);而「新 kind 三处都处置了、却忘了登记
307
+ * 进本表」这半条缝,由 `test/warning-projection-derived-4.0.0.test.ts` 的闭集格看着(本表 == 从 AST 解出的
308
+ * 臂集,逐字同集同序)。 */
309
+ export declare const WARNING_KINDS: readonly ["hosts-grandfathered", "domain-defaulted", "unknown-keys-dropped", "unknown-keys-carried", "legacy-governance-lifted", "entry-dropped", "unread-config-file"];
310
+ export type WarningKind = (typeof WARNING_KINDS)[number];
311
+ /** 逐臂剥字段的**分配式**映射:`W` 是裸类型参数,条件类型因此对联合的每一员分别作用(而不是先塌成交集)。 */
312
+ type PerArmOmit<W, K extends string> = W extends unknown ? Omit<W, K> : never;
313
+ /**
314
+ * 一条告警的**投影** = 该臂**减掉 `error`**,逐臂**机器派生**(4.0.0 BREAKING:属主从
315
+ * `./conformance` 搬回这里,形从手写字段表改成派生)。
316
+ *
317
+ * 为什么投影要剥掉 `error`:它带的是 zod 的错误对象/文案,可经脱敏、可随 zod 小版本改字 —— 比文案就是比
318
+ * 实现。`error` 因此永不进比对(但也不是就这么放过去了:kit 的 `findSecretLeaks` 先断言它不含语料自带的
319
+ * 秘密清单)。
320
+ *
321
+ * 为什么必须派生:4.0.0 之前这是一张**手写**的字段表(住在 `src/conformance/warnings.ts`),与本联合之间
322
+ * 没有任何字段级约束 ⇒ 给已有 kind 加一个字段,投影不动、`projectWarning` 不动、跨仓一致性门照绿,新字段
323
+ * 就这样一路穿过 kit,两边比的是一份少一个字段的告警(而都显示全绿)。派生之后,加字段 = `projectWarning`
324
+ * 那张表立刻编译红(门③ 用逐臂变异证过:改前段内诊断恒 0,改后每臂 ≥1)。
325
+ *
326
+ * 分配律是这里的承重位:直接写 `Omit<EffectiveReadWarning, "error">` 会先把联合塌成交集再剥,`keys` /
327
+ * `path` / `file` 这些**只属于某几臂**的字段会整片消失 —— 所以走 {@link PerArmOmit}(裸类型参数的条件
328
+ * 类型对联合**逐员**作用)。
329
+ */
330
+ export type WarningProjection = PerArmOmit<EffectiveReadWarning, "error">;
331
+ /**
332
+ * 一条告警 → 它的投影。**闭集穷举**:新 kind 不在这里表态就编译红(#157 的闭集纪律);而字段级的对齐由
333
+ * {@link WarningProjection} 的派生保证 —— 少铸一个字段,这里就是编译红。
334
+ *
335
+ * `truncatedFrom` 缺席时**不铸这个键**(不是铸 `undefined`)—— 规范化会把 `undefined` 值剔掉,两种写法
336
+ * 最终同形,但不铸更诚实:「没有截断」与「截断了 0 条」不该在中间形上分成两件事。
337
+ */
338
+ export declare function projectWarning(w: EffectiveReadWarning): WarningProjection;
299
339
  /**
300
340
  * 一条告警里的**段坐标**({@link ConfigPath})从「抢救压缩后的坐标」回写成「原文档的坐标」—— 纯函数,
301
341
  * {@link EntryKeptIndex} 是它唯一的坐标账。
@@ -305,7 +345,7 @@ export type EffectiveReadWarning = {
305
345
  * `models.1` 指的是压缩后的第 1 条。运维照着这条告警去改原文档的第 1 条 —— 改的是别人的行,而且改完
306
346
  * 告警还在(他以为改对了)。**告警必须说运维看得见的那份文档里的坐标**。
307
347
  *
308
- * 为什么回写在这里、而不是让扫描函数认识原文档:给 `scanDropped` / `scanCarried` 加一个「原始 raw」
348
+ * 为什么回写在这里、而不是让扫描函数认识原文档:给 {@link droppedKeys} / {@link carriedUnknownKeys} 加一个「原始 raw」
309
349
  * 第二入参,等于让扫描同时持有两套坐标,每条新告警都要自己决定用哪套。回写是**告警出门前**的一次
310
350
  * 坐标换算,与诊断本身正交 —— 一个铸点,所有臂一次表态。
311
351
  *
@@ -358,6 +398,21 @@ export declare const EXTENSION_NAMESPACES: Partial<Record<DomainName, ReadonlyAr
358
398
  * 本身——键名里可以含 `.`(`semaX.foo` 是一个键不是两层),段形让这件事不必再靠文法猜;前缀契约就是前缀
359
399
  * 契约(`semaGPT_5` / `semaX-v2` 都是成员,后缀不设未声明的文法)。 */
360
400
  export declare function isReservedExtensionKey(domain: DomainName, parent: ConfigPath, key: string): boolean;
401
+ /**
402
+ * 一次未知键扫描的结果:`keys` 有界(≤ {@link DROP_SCAN_MAX_KEYS}),`total` 是**截断前**的真实未知键数
403
+ * (超界仍数完 —— 扫描本就 O(文档键数),上限只界**告警载荷**不界扫描);`total > keys.length` ⇔ 列表被截断。
404
+ *
405
+ * **4.0.0(BREAKING)**:{@link droppedKeys} / {@link carriedUnknownKeys} 从「只回 `keys` 数组」改成回本形。
406
+ * 成案:上界与真实总数此前都是私有的,于是一个自装配读腿的消费方(不走 {@link parseDomainLoud} 的
407
+ * `onWarning`、自己拿这两只函数做诊断)在**结构上**铸不出 `truncatedFrom` —— 拿到 64 条,既不知道是不是刚好
408
+ * 64 条,也没有第二个公开面可以问「一共多少」。而本包自己的告警面从 1.5.0 起就带着 `truncatedFrom`:
409
+ * 同一件事在两个公开面上一个说、一个不说。上界 {@link DROP_SCAN_MAX_KEYS} 仍然**私有** —— 它是告警载荷的
410
+ * 上界,不是契约(改它不该让任何消费方红);消费方要的是「有没有截断、截断前多少条」,那就是 `total`。
411
+ */
412
+ export interface UnknownKeyScan {
413
+ keys: ConfigPath[];
414
+ total: number;
415
+ }
361
416
  /**
362
417
  * 一份域文档里被域 schema **丢掉的键**(zod 对象默认 `"strip"`),段坐标形({@link ConfigPath})。
363
418
  *
@@ -373,20 +428,29 @@ export declare function isReservedExtensionKey(domain: DomainName, parent: Confi
373
428
  * `runtime` 域的治理三件({@link GOVERNANCE_MIRROR_KEYS})**不算被丢**:0.5 期 runtime 文档携带它们是
374
429
  * 成文的抬升位(本文件的 governance 抬升函数会把它们抬进 governance 域),报出来是噪声。豁免只在顶层。
375
430
  *
431
+ * 返回 {@link UnknownKeyScan}:`keys` 是**有界**的点名列表,`total` 是截断前的真实总数(4.0.0 BREAKING,
432
+ * 此前只回 `keys` —— 见 {@link UnknownKeyScan} 的成案)。本包自己的告警(`unknown-keys-dropped`)也是拿这两个
433
+ * 数拼出来的,**一个铸点**:消费方自铸的 `truncatedFrom` 与告警里那个恒等。
434
+ *
376
435
  * 边界(如实登记):① 深度上限 {@link DROP_SCAN_MAX_DEPTH}、键数上限 {@link DROP_SCAN_MAX_KEYS}(告警
377
- * 载荷有界,超出即少报);② 数组长度不同就整段跳过(元素级裁剪由各自的 clamp 告警负责);③ 只比对纯
378
- * JSON 对象——raw 若是类实例/Proxy 这类形状不扫;④ 读 raw 的值可能触发访问器,整段包 try/catch 且**绝不**
379
- * 让其错误外泄(错误文案可能带 secret,与 parseDomain 的 R13 同纪律),扫描失败即当作"没看见",不影响解析。
436
+ * 载荷有界,超出即少报**点名**——`total` 不少报);② 数组长度不同就整段跳过(元素级裁剪由各自的 clamp 告警
437
+ * 负责);③ 只比对纯 JSON 对象——raw 若是类实例/Proxy 这类形状不扫;④ 读 raw 的值可能触发访问器,整段包
438
+ * try/catch 且**绝不**让其错误外泄(错误文案可能带 secret,与 parseDomain 的 R13 同纪律),扫描失败即当作
439
+ * "没看见"(`{ keys: [], total: 0 }`),不影响解析。
380
440
  */
381
- export declare function droppedKeys(domain: DomainName, raw: unknown, parsed: unknown): ConfigPath[];
441
+ export declare function droppedKeys(domain: DomainName, raw: unknown, parsed: unknown): UnknownKeyScan;
382
442
  /**
383
443
  * 一份已解析的 open-world 域文档里,**本包不认识但已承运**的键(段坐标)。非 open-world 域恒空
384
444
  * (它们的未知键走 {@link droppedKeys} 那条更重的「被丢了」告警)。
385
445
  *
446
+ * 返回 {@link UnknownKeyScan}(4.0.0 BREAKING,与 {@link droppedKeys} 同形同律:`keys` 有界、`total` 是
447
+ * 截断前的真实总数)。非 open-world 域返回的也是这个形(`{ keys: [], total: 0 }`),不是别的东西 ——
448
+ * 「这个域没有承运未知键」与「这个域不参与承运」在消费方那里是同一个处置。
449
+ *
386
450
  * 边界(如实):只看 {@link OPEN_WORLD_KNOWN_KEYS} 登记了的层级 —— 未登记层级(如某个未知键自己的
387
451
  * 子树)不再下探,那本就是消费方自定义的内容,逐层猜没有意义。
388
452
  */
389
- export declare function carriedUnknownKeys(domain: DomainName, parsed: unknown): ConfigPath[];
453
+ export declare function carriedUnknownKeys(domain: DomainName, parsed: unknown): UnknownKeyScan;
390
454
  /**
391
455
  * 一域的 parse + **静默剥键/静默承运两条告警**(0.19.0)。`buildEffective` 与 `FileConfigStore.getDomain`
392
456
  * 共用这一个判据 —— 此前只有 effective 那条路径有门,而 `getDomain` 是 `exportBundle` 的读法
@@ -488,3 +552,4 @@ export declare function readEffectiveWire(raw: unknown, onWarning?: (w: Effectiv
488
552
  * 组内一档未绑 → `undefined`(引擎档位层恒惰性,与「未配 tiers」逐字同义)。消费方(center /effective
489
553
  * 投影、service applyEffective 接线)都走这一个函数,不要各自解析。 */
490
554
  export declare function resolveActiveTiers(models: Pick<DomainConfig["models"], "tierGroups" | "activeTierGroup">): Record<string, string> | undefined;
555
+ export {};