@sema-agent/settings-schema 12.2.0 → 12.3.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,64 @@
6
6
  > `@sema-agent/registry-core`(更早还曾是 `@sema-ai/registry-core`)。本档 `0.19.0` 以下各节里的
7
7
  > 包名是**历史记录**,如实保留不追改。
8
8
 
9
+ ## 12.3.0 — 2026-10-12 — minor:域读策略两张闭集表(新导出 `DOMAIN_ENTRY_SALVAGE`)· 域读分文档面 / 视图面两函数(新导出 `parseDomainSalvaged`)· 抢救按 schema 递归到嵌套集合 · `projects` 起逐条抢救
10
+
11
+ > minor:域读策略拆成两张闭集表 —— 新导出 `DOMAIN_ENTRY_SALVAGE`(读时能否逐条丢坏条目,`"salvage" | "none"`),`DOMAIN_READ_FALLBACK` 名 / 值 / 义不变(只答「整域读不进」);
12
+ > 域读分两面两函数 —— 文档面 `parseDomainLoud`(读数与 12.2.x 逐字同,永不抢救)与新导出的视图面 `parseDomainSalvaged`(按表逐条抢救,递归到嵌套集合);`projects` 起逐条抢救。
13
+ > additive:零删、零改名;导出面新增两行(`DOMAIN_ENTRY_SALVAGE` / `parseDomainSalvaged`);好配置的读数逐字不变;写路径、写入席、`getDomain` / `exportBundle` 的读数逐字不变。
14
+
15
+ ### 两张表各答一个问题:`DOMAIN_ENTRY_SALVAGE` 与 `DOMAIN_READ_FALLBACK`
16
+
17
+ 此前「能否逐条丢一条坏条目」挂在 `DOMAIN_READ_FALLBACK` 的 `"default"` 档上 —— 与「整域读不进时能否退默认」被同一张表回答。`projects` 两件事的答案相反
18
+ (丢一条登记 = 那个项目认领被拒,fail-closed;整域退默认 = 身份台账蒸发,放权),于是只能整域抛:一条存量登记被后来收紧的 schema 拒,**整个 scope 的项目**一起停摆。
19
+ 现在两张表:`DOMAIN_ENTRY_SALVAGE`(`as const satisfies Record<DomainName, "salvage" | "none">`,新域不表态 = 编译红,头注逐域理由)只管逐条丢;
20
+ `DOMAIN_READ_FALLBACK` 只管整域读不进之后默认化还是抛。逐域:`projects` = `salvage`(整域档位仍 `"throw"`);闸域 `rosters` / `workers` / `governance` / `entitlement` /
21
+ `execution` / `limits` = `none`(丢一条规则行 = 静默放权;`workers` 的逐行容错仍是 `readWorkersRows` 的扣留);其余目录 / 内容域 = `salvage`(与 12.2.x 同)。
22
+
23
+ ### 两面两函数:文档面 `parseDomainLoud` 永不抢救,视图面 `parseDomainSalvaged` 按表抢救
24
+
25
+ 文档面(`parseDomain` / `parseDomainLoud`:写入席、导出、`getDomain`)永不抢救;视图面(`parseDomainSalvaged` / `buildEffective`)按表抢救。
26
+ 两个读函数共用**同一只内部体**(严 parse + 未知键两条告警),只差「整域读不进」那一臂:文档面抛原错(与 12.2.x 逐字同 —— `FileConfigStore.getDomain`、
27
+ `exportBundle`、`createSiblingResolver`、一致性门写入席 `strictWrite` 腿零改);视图面 `parseDomainSalvaged(domain, raw, onWarning?)` 按 `DOMAIN_ENTRY_SALVAGE`
28
+ 走 `salvageEntries` → 丢了东西就拿压缩后的文档再 parse;过 ⇒ 每条被丢条目一条 `entry-dropped`,其余告警的坐标经 `rebaseWarningPaths` 回写成原文档坐标;救不活 ⇒
29
+ 抛整域 parse 的原错、零告警。同一份**好**文档上两函数逐字同值、同告警。`buildEffective` 十六个域一处调视图面,整域读不进时只看 `DOMAIN_READ_FALLBACK`(此前 gate 域的行
30
+ 与其余域的行各调一只函数 —— 同一张表的第二份手抄,删了)。管理面要显示「生效配置里这个域是什么」时读视图面;读存量、改、整份写回的「现状」读文档面
31
+ (经抢救读出再写回,被丢的条目就成了删除)。
32
+
33
+ ### 抢救递归到嵌套集合
34
+
35
+ `salvageEntries`:集合里每一条,若它的 schema 是带集合字段的对象,先对它按**同一个函数**抢救,再拿抢救后的那一条判整条(从 schema 派生,没有任何域专用分支)。
36
+ 体内丢的子条目以父坐标为前缀上账(`["projects", <id>, "contextItems", 1]`),按文档序;整条最终仍判不过 ⇒ 整条丢、体内的半路账作废,记账行的判决取原文的(拒句坐标是原文档的)。
37
+ 零幸存者律在每一层成立(体内集合全坏 ⇒ 不压成空集合 —— 「空 = 不设限」的字段不会被抢救成全开)。
38
+ `EntryKeptIndex` 的键 = 被压缩数组的原文档全路径(`formatConfigPath` 出门形;顶层数组的全路径就是字段名,12.2.x 的表形与义不变);`rebaseWarningPaths` 逐段回写
39
+ 每一层被压缩的数组(两层都压缩时两层各回写一次)。
40
+
41
+ 目录 / 内容域因此也变:条目体内某个子条目坏了,此前整条丢,现在只丢那个子条目、整条留下。被递归够到的嵌套集合(逐个,`salvage` 档域里条目体内的全部集合字段):
42
+
43
+ | 域 | 嵌套集合 | 少一条的语义 |
44
+ |---|---|---|
45
+ | models | `models[].promptGuidance[]` | 少一条提示指引 |
46
+ | skills | `skills[].scenarios[]` · `skills[].rosters[]` | 适用范围收窄(两者「空 = 全部」,零幸存者律不压成空) |
47
+ | mcp | `servers[].allowTools[]` · `servers[].scenarios[]` · `servers[].rosters[]` | 暴露的工具 / 挂载范围收窄(三者「空 / 缺席 = 全部」,同上) |
48
+ | systems | `systems[].callsWorkers[]` | 少给一个 worker 下发调用凭据 |
49
+ | collab | `templates[].slots[]` → `slots[].members[]` · `slots[].tools[]` | slot / member 少一个:模板级判据(拓扑、重名)仍过时,这是「换了一个更小的模板在跑」而不是收权 —— **推断,无逐域钉**;`tools[]` 收窄 |
50
+ | hosts | `hosts[].labels[]` | 少一个调度标签:匹配 `placement.requires` 的机器变少 |
51
+ | prompts | `bindings[].declarations[]` | 少下发一条声明 |
52
+ | projects | `projects.<id>.gitRemotes[]` · `.defaultScopes[]` · `.contextItems[]` | 少一条 remote 映射 / 默认记忆 scope / 上下文条目 |
53
+
54
+ 三问 —— **谁需要**:任何一次收紧持久化 schema 的版本(12.2.1 的 UNC 判法已让存量登记 `\\?\C:\…` 整 scope 读抛)读存量文档的一方 —— 服务端拿 effective、管理面列项目;
55
+ **谁受伤**:无 —— 好配置读数逐字不变;文档面(写入席、导出、`getDomain`)读数逐字不变;视图面此前整域抛 / 整条丢的文档现在读出更多(只丢坏的那一格并点名);
56
+ **补偿**:闸域显式 `none`(两张表都说不);文档面永不抢救;读改写面读文档面。
57
+ 钉:`test/projects-12.3.0.test.ts`(两表闭集 · 12.2.1 存量形黑盒读数 · 视图面与 `buildEffective` 同读数 · 文档面 / `getDomain` / `exportBundle` 与 12.2.x 逐字同 ·
58
+ 两函数好文档同值同告警 · 整域档位不变 · 闸域负控 · 写路径与写入席拒 · 递归坐标 / 位移表 / 每层零幸存者 / 判决取原文)·
59
+ `test/entry-salvage-1.11.test.ts`(按新表分组;workers / entitlement / projects 三格空转修成真档位格)· `test/domain-completeness.test.ts` ⑤ ·
60
+ `test/conformance-coverage.test.ts` ⑥ · 语料 `c76-projects-entry-salvage-nested`(读腿抢救 + 嵌套坐标回写 · 严写腿拒)。
61
+
62
+ ## 12.2.1 — 2026-10-12 — patch:`isAbsoluteHostPath` 的 UNC 首段按主机名形判(字母数字起头),Windows 设备命名空间前缀 `\\.\` 与长路径前缀 `\\?\` 一律拒(11.3.1 / 12.1.1 只排除 `?` / `*`,`\\.\C:\…` 曾被当 UNC 受理)
63
+
64
+ - **行为修复(宿主路径形判,UNC 臂)**。三族律不变;UNC 首段 = 主机名形(`[A-Za-z0-9][A-Za-z0-9._-]*`),所以 `\\.\C:\x`、`\\.\pipe\x`、`\\?\C:\x`、`\\*\share`、`\\-srv\share` 都不绝对;`\\srv\share\…` / `\\10.0.0.5\share` 照旧真。posix / 盘符两族逐字不变。
65
+ 三问 —— **谁需要**:两种设备前缀同一判法(此前一受理一拒,读者无法解释);**谁受伤**:无(设备命名空间路径不是任务 `cwd` / 项目登记的形);**补偿**:无。钉:`test/host-path-11.3.1.test.ts` 不绝对形加两形。
66
+
9
67
  ## 12.2.0 — 2026-10-12 — minor:`mcp.servers[]` 条目三只解析透明可选键 `toolAxes` / `authForm` / `entryKind` · http 传输可选 `assertionHeader`(与 `principalHeader` 互斥)
10
68
 
11
69
  > minor:`mcp.servers[]` 条目上多三只**解析透明**的可选键 `toolAxes` / `authForm` / `entryKind`;http 形传输上多一只可选键 `assertionHeader`(HTTP 头名),与 `principalHeader` 互斥。
package/README.md CHANGED
@@ -371,8 +371,15 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
371
371
  primitive). **2.0.0** adds per-ENTRY
372
372
  tolerance on the same read path: one malformed catalog entry is dropped on its own (named through
373
373
  `onWarning` as `entry-dropped`) and its siblings survive, instead of the whole domain falling to
374
- its schema default. It is the SAME `DOMAIN_READ_FALLBACK` table that decides where this applies — gate
375
- domains keep failing loud, entry by entry included. **S-712** (additive) lets each `entry-dropped` carry an
374
+ its schema default. **12.3.0**: where this applies is its OWN closed table, `DOMAIN_ENTRY_SALVAGE`
375
+ (`"salvage" | "none"`; until 12.2.x it rode on `DOMAIN_READ_FALLBACK`'s `"default"` tier, which now answers only
376
+ "the whole domain is unreadable — default or throw?") — gate domains (rosters / workers / governance / entitlement /
377
+ execution / limits) never drop a single entry, `projects` does (a missing registration = that project's claim is
378
+ refused; the domain stays `"throw"` when unreadable as a whole). Domain reading has two faces with two functions
379
+ sharing ONE body (strict parse + the unknown-key warnings): the DOCUMENT face (`parseDomain` / `parseDomainLoud` — the
380
+ write seat, export, `getDomain`) never salvages and reads exactly as 12.2.x; the VIEW face (`parseDomainSalvaged`, which
381
+ `buildEffective` calls for every domain) salvages per the table, recursing into a surviving entry's own collections
382
+ (coordinates carry the parent prefix, the zero-survivor law holds at every level). **S-712** (additive) lets each `entry-dropped` carry an
376
383
  optional `identity?: { name?, id?, enabled? }` read from the dropped entry's OWN text (`DroppedEntryIdentity`):
377
384
  a string `name` / `id` and a boolean `enabled`, own data properties only, each key present only when it
378
385
  reads with that exact type and `isDisclosableIdentity` says it may ride out (not guessed, not coerced, not
@@ -1 +1 @@
1
- 12.2.0
1
+ 12.3.0
@@ -0,0 +1,142 @@
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
+ "atMention": {
30
+ "mode": "all"
31
+ },
32
+ "models": [],
33
+ "roles": {},
34
+ "tierGroups": []
35
+ },
36
+ "plugins": {
37
+ "plugins": []
38
+ },
39
+ "projects": {
40
+ "projects": {
41
+ "01890a5d-ac96-774b-bcce-b302099a8001": {
42
+ "defaultScopes": [
43
+ "proj"
44
+ ],
45
+ "displayName": "one",
46
+ "gitRemotes": [],
47
+ "registeredAt": "2026-10-01T00:00:00.000Z",
48
+ "registeredBy": "alice"
49
+ },
50
+ "01890a5d-ac96-774b-bcce-b302099a8002": {
51
+ "contextItems": [
52
+ {
53
+ "kind": "folder",
54
+ "value": "/srv/two"
55
+ },
56
+ {
57
+ "kind": "note",
58
+ "value": "keep"
59
+ }
60
+ ],
61
+ "defaultScopes": [
62
+ "proj"
63
+ ],
64
+ "displayName": "two",
65
+ "gitRemotes": [],
66
+ "registeredAt": "2026-10-01T00:00:00.000Z",
67
+ "registeredBy": "alice"
68
+ }
69
+ }
70
+ },
71
+ "prompts": {
72
+ "bindings": []
73
+ },
74
+ "rosters": {
75
+ "rosters": []
76
+ },
77
+ "scenarios": {
78
+ "scenarios": []
79
+ },
80
+ "skills": {
81
+ "skills": []
82
+ },
83
+ "systems": {
84
+ "systems": []
85
+ },
86
+ "workers": {
87
+ "workers": []
88
+ }
89
+ },
90
+ "warnings": [
91
+ {
92
+ "domain": "projects",
93
+ "kind": "entry-dropped",
94
+ "path": [
95
+ "projects",
96
+ "01890a5d-ac96-774b-bcce-b302099a8002",
97
+ "contextItems",
98
+ 1
99
+ ],
100
+ "reasonCode": "entry-schema-invalid"
101
+ },
102
+ {
103
+ "domain": "projects",
104
+ "kind": "entry-dropped",
105
+ "path": [
106
+ "projects",
107
+ "01890a5d-ac96-774b-bcce-b302099a8003"
108
+ ],
109
+ "reasonCode": "entry-schema-invalid"
110
+ },
111
+ {
112
+ "domain": "projects",
113
+ "keys": [
114
+ [
115
+ "projects",
116
+ "01890a5d-ac96-774b-bcce-b302099a8002",
117
+ "contextItems",
118
+ 2,
119
+ "typo"
120
+ ]
121
+ ],
122
+ "kind": "unknown-keys-dropped"
123
+ }
124
+ ],
125
+ "metadata": {
126
+ "version": 18,
127
+ "updatedAt": "2026-10-13T00:00:00.000Z"
128
+ }
129
+ },
130
+ "strictWrite": {
131
+ "outcome": "rejected",
132
+ "stage": "validate",
133
+ "why": "拒:读腿要逐条丢两条才读得进的文档,写入席整份拒 —— 写入席不替运维删他的条目(写路径永不抢救)。"
134
+ },
135
+ "wire": {
136
+ "derive": true
137
+ },
138
+ "localRoot": {
139
+ "derive": true,
140
+ "localVersion": 2617555914511015
141
+ }
142
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "meta": {
3
+ "version": 18,
4
+ "updatedAt": "2026-10-13T00:00:00.000Z"
5
+ },
6
+ "domains": {
7
+ "projects": {
8
+ "projects": {
9
+ "01890a5d-ac96-774b-bcce-b302099a8001": {
10
+ "displayName": "one",
11
+ "gitRemotes": [],
12
+ "defaultScopes": [
13
+ "proj"
14
+ ],
15
+ "registeredBy": "alice",
16
+ "registeredAt": "2026-10-01T00:00:00.000Z"
17
+ },
18
+ "01890a5d-ac96-774b-bcce-b302099a8002": {
19
+ "displayName": "two",
20
+ "gitRemotes": [],
21
+ "defaultScopes": [
22
+ "proj"
23
+ ],
24
+ "registeredBy": "alice",
25
+ "registeredAt": "2026-10-01T00:00:00.000Z",
26
+ "contextItems": [
27
+ {
28
+ "kind": "folder",
29
+ "value": "/srv/two"
30
+ },
31
+ {
32
+ "kind": "folder",
33
+ "value": "\\\\?\\C:\\x"
34
+ },
35
+ {
36
+ "kind": "note",
37
+ "value": "keep",
38
+ "typo": 1
39
+ }
40
+ ]
41
+ },
42
+ "01890a5d-ac96-774b-bcce-b302099a8003": {
43
+ "displayName": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
44
+ "gitRemotes": [],
45
+ "defaultScopes": [
46
+ "proj"
47
+ ],
48
+ "registeredBy": "alice",
49
+ "registeredAt": "2026-10-01T00:00:00.000Z"
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "contributor": "contract",
3
+ "claim": "12.3.0(S-1494)projects 逐条抢救 + 抢救递归:三条登记里第 2 条的第 1 个上下文条目是 Windows 长路径前缀形(12.2.1 起拒)、第 3 条 displayName 201 字。读腿:登记 1、2 在(2 的 contextItems 丢第 1 项,其余照用)、3 不在,两条 entry-dropped 分别点到嵌套那一格与整条登记;第 2 条第 2 项上的拼错键按**原文档**下标报(压缩后它是第 1 项)。严写腿整份拒。打的是三种读法分歧:projects 仍整域拒(12.2.x 读法,整个 scope 的项目停摆)/ 抢救只下到顶层(整条登记 2 连坐丢掉)/ 嵌套坐标报压缩后的下标。",
4
+ "firstRedOn": [],
5
+ "note": "firstRedOn 为空:historical 阶梯(≤ 2.1.0)不覆盖 11.3.0 的 projects 扩形;对 12.2.x 这一条会红(projects 整域拒),那一版不在入库的真跑证据里。"
6
+ }
@@ -263,11 +263,16 @@ export declare function isReportableRefusalName(name: string): boolean;
263
263
  * 摘要输入取 `JSON.stringify(name)`:UTF-8 编码会把孤立代理项换成 U+FFFD(`"\ud800"` / `"\ud801"` / `"\ufffd"` 三个不同的名同一串字节),
264
264
  * JSON 串形逐个 UTF-16 码元保留(孤立代理项写成转义)—— 不同的名恒是不同的输入。 */
265
265
  export declare function withheldNameToken(name: string): string;
266
- /** 抢救造成的**下标位移表**:数组字段名 → 该字段压缩后每个位置的**原**下标(`kept[j]` = 压缩后第 j 条
266
+ /** 抢救造成的**下标位移表**:被压缩过的数组 → 它压缩后每个位置的**原**下标(`kept[j]` = 压缩后第 j 条
267
267
  * 在原文档里的下标)。纯数据,无行为。
268
268
  *
269
- * 只登记**真发生过丢弃**的数组字段 —— 没丢的字段没有位移,给它登记一份恒等映射只会把「有没有位移」
270
- * 从表的**在场性**挪成表的**内容**,凭空多一层判据。Record 字段永不登记:它的条目按键寻址,丢一条
269
+ * **键 = 那个数组在原文档里的全路径**,经 {@link formatConfigPath} 出门形(12.3.0,S-1494:抢救递归之后,被压缩的数组可以住在
270
+ * 幸存条目体内 —— `"projects.<id>.contextItems"`、`"items.2.subs"`)。顶层数组字段的全路径就是它的字段名(`"models"`),所以
271
+ * 12.2.x 及更早的表(`{ models: [0, 2] }`)形与义都不变。路径段是出门形(Record 键过脱敏器,与告警坐标同一道):两只不同的、
272
+ * 都被打码成同一串的 Record 键体内各有一个压缩数组时,那一格坐标本身就分不清是谁 —— 那个键不登记(不回写好过回写错)。
273
+ *
274
+ * 只登记**真发生过丢弃或位移**的数组 —— 没丢的数组没有位移,给它登记一份恒等映射只会把「有没有位移」
275
+ * 从表的**在场性**挪成表的**内容**,凭空多一层判据。Record 永不登记:它的条目按键寻址,丢一条
271
276
  * 不动其余键的坐标。 */
272
277
  export type EntryKeptIndex = Readonly<Record<string, readonly number[]>>;
273
278
  /** {@link salvageEntries} 的结果。`raw` 是剔掉坏条目后的文档;**什么都没丢时返回原引用**
@@ -304,10 +309,19 @@ export interface EntrySalvageResult {
304
309
  * 抛出来的文案可能带 secret。抢救是**尽力而为**的:抢不动就当没抢救(返回原引用),让既有的整域档位去裁,
305
310
  * 绝不把解析拖下水、更不回显它的错误(与同文件 {@link droppedKeys} 逐字同纪律)。
306
311
  *
307
- * 边界(如实登记):① 只看**顶层**字段(条目集合就在顶层,嵌套的坏形属于条目自身的问题);② raw 不是
312
+ * **递归**(12.3.0,S-1494):集合里的每一条,先对它自己按**同一个函数**抢救(它的 schema 若是带集合字段的对象 —— 从 schema 派生,
313
+ * 没有任何域专用分支),再拿抢救后的那一条去判整条。体内丢的子条目以父坐标为前缀上账(`["projects", <id>, "contextItems", 1]`),
314
+ * 体内压缩过的数组以全路径进位移表({@link EntryKeptIndex});整条最终仍判不过 ⇒ 整条丢,它体内的半路账一并作废(一条一个结论),
315
+ * 记账行的判决取**原文**的(拒句坐标是原文档的,不是抢救形里压缩过的下标)。
316
+ * 零幸存者律在**每一层**成立 —— 体内的集合全坏 ⇒ 不压成空集合,那一条照原样去判(于是整条丢):嵌套里同样有「空 = 不设限」的字段
317
+ * (`skills[].scenarios` / `mcp.servers[].allowTools` …),抢救成 `[]` 就是把限定范围放成全开。账按文档序排(父条目丢在哪个位置,
318
+ * 体内的子条目就排在哪个位置)。
319
+ *
320
+ * 边界(如实登记):① 只下钻**条目**(集合的元素 / 值),不下钻顶层的普通对象字段(`models.atMention` 不是条目);② raw 不是
308
321
  * 纯 JSON 对象、字段值与 schema 形状对不上(schema 说数组、文档给对象)⇒ 原样返回,交给整域档位裁;
309
322
  * ③ 域级判据(重名 / 悬空 ref)不在本函数视野内 —— 丢条目救不了它们,调用方据此回落整域档位;
310
- * ④ 只认 `ZodArray` / `ZodRecord` 两种条目集合形 —— 别的形(元组等)不抢救,退回整域档位(保守方向)。
323
+ * ④ 只认 `ZodArray` / `ZodRecord` 两种条目集合形 —— 别的形(元组等)不抢救,退回整域档位(保守方向);⑤ 递归深度止于 schema
324
+ * (本包没有自引用 schema);`z.lazy` 包着的 schema 不展开(`unwrapSchema` 不认它)⇒ 不下钻,那一条按原样判。
311
325
  */
312
326
  export declare function salvageEntries(schema: z.ZodType, raw: unknown): EntrySalvageResult;
313
327
  /**
@@ -362,7 +376,8 @@ export type EffectiveReadWarning = {
362
376
  /** 一条**条目**没通过它自己的 entry schema,被单独丢掉了(域内其余条目照常生效)。`path` = 段坐标
363
377
  * (`["models",3]` / `["roles","summarize"]`),与 `unknown-keys-dropped` 的坐标形同一套。逐条一发 —— 载荷是
364
378
  * O(域里的条目数),与被读文档同阶,不放大,故不设条数上限(有上限的是**单条**告警里的键列表)。
365
- * 2.0.0 起;只可能来自 {@link DOMAIN_READ_FALLBACK} 判 `"default"` 的域,gate 域坏形仍然 throw。
379
+ * 2.0.0 起;只可能来自 {@link DOMAIN_ENTRY_SALVAGE} 判 `"salvage"` 的域(12.3.0 起该表单列;此前挂在 `DOMAIN_READ_FALLBACK` 的
380
+ * `"default"` 档上),闸域坏条目仍然整域读不进。`path` 可以落在幸存条目体内(12.3.0 递归:`["projects", <id>, "contextItems", 1]`)。
366
381
  * **S-712**(additive):`identity` = 被丢条目原文里读得出的身份({@link DroppedEntryIdentity};
367
382
  * 读不出 ⇒ 键缺席),消费方据此知道「丢的是谁」而不必拿坐标回读原文档。
368
383
  * **S-1295**(additive):`reasonCode` = 为什么丢的机读闭集词({@link DROPPED_ENTRY_REASON_CODES}),消费方据此知道「为什么丢」
@@ -486,12 +501,34 @@ export declare const EXTENSION_NAMESPACES: Partial<Record<DomainName, ReadonlyAr
486
501
  * 契约(`semaGPT_5` / `semaX-v2` 都是成员,后缀不设未声明的文法)。 */
487
502
  export declare function isReservedExtensionKey(domain: DomainName, parent: ConfigPath, key: string): boolean;
488
503
  /**
489
- * 一域的 parse + **静默剥键/静默承运两条告警**(0.19.0)。`buildEffective` 与 `FileConfigStore.getDomain`
490
- * 共用这一个判据 —— 此前只有 effective 那条路径有门,而 `getDomain` 是 `exportBundle` 的读法
504
+ * **文档面**的域读函数:一域的严 parse + **静默剥键/静默承运两条告警**,**永不抢救**。`FileConfigStore.getDomain`(于是 `exportBundle`、
505
+ * `createSiblingResolver`)与一致性门写入席(`strictWrite` 腿)读它 —— 此前只有 effective 那条路径有门,而 `getDomain` 是 `exportBundle` 的读法
491
506
  * (对抗复审 复审 round2 F3:导出一份被剥了键的域再导入 = 持久的配置丢失,而且全程无声)。
507
+ *
508
+ * 文档面回答「这份文档本身读不读得进」:读不进就抛(sanitized ZodError),一条条目都不替运维删 —— 写入席、导出、读改写的「现状」读的都是它。
509
+ * 视图面(「这份文档在生效配置里是什么」)是 {@link parseDomainSalvaged}:同一只内部体({@link readDomainDocument}),读不进时多一步逐条抢救。
492
510
  */
493
511
  export declare function parseDomainLoud<K extends DomainName>(domain: K, raw: unknown, onWarning?: (w: EffectiveReadWarning) => void): DomainConfig[K];
512
+ /**
513
+ * **视图面**的域读函数(12.3.0,S-1494):与 {@link parseDomainLoud} 同一只内部体,整域读不进时按 {@link DOMAIN_ENTRY_SALVAGE} 逐条抢救。
514
+ * `buildEffective` 十六个域一处调它(`parseOr`);管理面要显示「生效配置里这个域是什么」时读它。
515
+ *
516
+ * 读法:
517
+ * 1. 整域 `parseDomain`;过 ⇒ 未知键两条告警按原文档坐标出门,返回 —— 与 {@link parseDomainLoud} 逐字同值同告警(好配置的路径一字不动)。
518
+ * 2. 不过,且 {@link DOMAIN_ENTRY_SALVAGE} 判该域 `"salvage"` ⇒ {@link salvageEntries}(递归到嵌套集合);丢了东西 ⇒ 拿压缩后的
519
+ * 文档再 `parseDomain` 一次。过 ⇒ 每条被丢条目一条 `entry-dropped`(它们本来就是原文档坐标),其余告警是对压缩文档的诊断,
520
+ * 过本层位移表({@link rebaseWarningPaths})回写成原文档坐标(坐标律 S-323b)。
521
+ * 3. 其余一切(`"none"` 档、什么都没丢、丢了仍读不进)⇒ **抛第 1 步的原错**,零告警 —— 一个域一个结论:没救活就不留「丢了某条」的半路叙述。
522
+ * 整域读不进之后是默认化还是抛,是调用方按 {@link DOMAIN_READ_FALLBACK} 的事(`buildEffective` 的 `parseOr`),本函数不读那张表。
523
+ *
524
+ * 写路径与导出面不调它:`parseDomain` / `validateDomain` / `writeDomainFile` 判整份文档,写入席与 `getDomain` / `exportBundle` 读 {@link parseDomainLoud}。
525
+ * 读改写(读存量、改、整份写回)的「现状」也不许读它 —— 抢救读出再写回,被丢的条目就成了删除。
526
+ */
527
+ export declare function parseDomainSalvaged<K extends DomainName>(domain: K, raw: unknown, onWarning?: (w: EffectiveReadWarning) => void): DomainConfig[K];
494
528
  /** Per-domain READ-corruption policy (0.8.1) — what buildEffective does when a stored domain no longer parses.
529
+ * **One question only** (12.3.0, S-1494): the domain as a whole is unreadable — after {@link parseDomainSalvaged} has tried
530
+ * whatever entry salvage {@link DOMAIN_ENTRY_SALVAGE} allows — so does the read default it or throw? Whether single bad
531
+ * entries may be dropped is the OTHER table's question; neither table reads the other.
495
532
  * `"default"` = degrade to the schema default + warn (safe: the default only REMOVES capability, fail-closed).
496
533
  * `"throw"` = keep fail-loud (defaulting would LOOSEN gates or destructively apply — worse than an outage,
497
534
  * consumers fail-static on last-good). A `satisfies Record<DomainName,…>` forces every FUTURE domain to make
@@ -534,6 +571,55 @@ export declare const DOMAIN_READ_FALLBACK: {
534
571
  readonly prompts: "default";
535
572
  readonly limits: "throw";
536
573
  };
574
+ /** Per-domain ENTRY-SALVAGE policy (12.3.0, S-1494) — may the view-face domain reader ({@link parseDomainSalvaged}) drop single bad
575
+ * entries (an array element, a Record value, and — recursively — a bad entry inside a surviving entry) and read the rest?
576
+ * **One question only**: what happens when the domain stays unreadable after that is {@link DOMAIN_READ_FALLBACK}'s question.
577
+ * `"salvage"` = drop each bad entry, name it (`entry-dropped`, coordinate + identity + reason word), keep the rest.
578
+ * `"none"` = never drop a single entry: the first bad entry makes the whole domain unreadable.
579
+ * A `satisfies Record<DomainName,…>` forces every FUTURE domain to answer this explicitly (compile error until classified).
580
+ *
581
+ * **View face only.** The document face never salvages: `parseDomain` / `validateDomain` / `writeDomainFile` judge the whole
582
+ * document, and {@link parseDomainLoud} — what the conformance write seat (`strictWrite` leg), `FileConfigStore.getDomain` and
583
+ * `exportBundle` read — throws on it. A write path that reads the stored "current" value through `parseDomainSalvaged` and writes
584
+ * it back would turn every dropped entry into a deletion — read-modify-write faces read the stored document with the document face.
585
+ *
586
+ * Per-domain rationale:
587
+ * - models/skills/mcp/plugins/scenarios/systems/collab/prompts: additive capability / content catalogs — a dropped entry is
588
+ * one capability gone (fail-closed). Inside a surviving entry the same holds: the nested collections these entries carry
589
+ * are allowlists / scopings (`allowTools`, `scenarios`, `rosters`, `callsWorkers`, …) where one entry fewer only NARROWS,
590
+ * and the zero-survivor law (never mint an empty collection — several of these read "empty = all") holds at every level.
591
+ * - hosts: a dropped host is one placement target fewer; a dropped label makes a host match fewer `placement.requires`.
592
+ * - projects: SALVAGE — a registration absent from the ledger = that project's claim is refused (fail-closed). Throwing the
593
+ * whole domain stops EVERY project of the scope, which protects nothing: one stored registration that a later, tighter
594
+ * schema refuses (12.2.1's UNC judgement on `contextItems[].value`) must not take the scope's other projects with it.
595
+ * Its whenUnreadable stance stays `"throw"` (a ledger that cannot be read at all is never defaulted — see
596
+ * {@link DOMAIN_READ_FALLBACK}).
597
+ * - rosters: NONE — a dropped roster makes resolveEffectiveForWorker fall back to the FULL models catalog for that worker
598
+ * (放权,不许).
599
+ * - workers: NONE — a dropped worker row reads as "this worker is no longer declared": the reconciler would retire it. The
600
+ * per-row tolerance this domain does have lives in `readWorkersRows` (rows are HELD with their original, never dropped).
601
+ * - governance: NONE — a dropped commandPolicy / approvalRequire row is a guardrail silently gone (放权,不许).
602
+ * - entitlement: NONE — a dropped tier / subject / binding / budget group changes who may do what (放权,不许).
603
+ * - execution: NONE — a dropped lane / public key changes the execution mandate (放权,不许).
604
+ * - limits: NONE — a dropped allowlist row changes a published ceiling (the domain is the one publishing seat of those ceilings). */
605
+ export declare const DOMAIN_ENTRY_SALVAGE: {
606
+ readonly models: "salvage";
607
+ readonly rosters: "none";
608
+ readonly skills: "salvage";
609
+ readonly mcp: "salvage";
610
+ readonly plugins: "salvage";
611
+ readonly scenarios: "salvage";
612
+ readonly systems: "salvage";
613
+ readonly collab: "salvage";
614
+ readonly workers: "none";
615
+ readonly hosts: "salvage";
616
+ readonly governance: "none";
617
+ readonly entitlement: "none";
618
+ readonly execution: "none";
619
+ readonly projects: "salvage";
620
+ readonly prompts: "salvage";
621
+ readonly limits: "none";
622
+ };
537
623
  /** 一个读者向店要文档的口:配置域,以及墓碑表里的retired 域({@link RETIRED_DOMAINS})—— 后者只为让墓碑看见没迁的旧文档。 */
538
624
  export type StoredDocumentReader = (d: DomainName | RetiredDomainName) => unknown;
539
625
  /**
@@ -419,78 +419,122 @@ function judgeEntry(schema, value, rejectedAs) {
419
419
  * 抛出来的文案可能带 secret。抢救是**尽力而为**的:抢不动就当没抢救(返回原引用),让既有的整域档位去裁,
420
420
  * 绝不把解析拖下水、更不回显它的错误(与同文件 {@link droppedKeys} 逐字同纪律)。
421
421
  *
422
- * 边界(如实登记):① 只看**顶层**字段(条目集合就在顶层,嵌套的坏形属于条目自身的问题);② raw 不是
422
+ * **递归**(12.3.0,S-1494):集合里的每一条,先对它自己按**同一个函数**抢救(它的 schema 若是带集合字段的对象 —— 从 schema 派生,
423
+ * 没有任何域专用分支),再拿抢救后的那一条去判整条。体内丢的子条目以父坐标为前缀上账(`["projects", <id>, "contextItems", 1]`),
424
+ * 体内压缩过的数组以全路径进位移表({@link EntryKeptIndex});整条最终仍判不过 ⇒ 整条丢,它体内的半路账一并作废(一条一个结论),
425
+ * 记账行的判决取**原文**的(拒句坐标是原文档的,不是抢救形里压缩过的下标)。
426
+ * 零幸存者律在**每一层**成立 —— 体内的集合全坏 ⇒ 不压成空集合,那一条照原样去判(于是整条丢):嵌套里同样有「空 = 不设限」的字段
427
+ * (`skills[].scenarios` / `mcp.servers[].allowTools` …),抢救成 `[]` 就是把限定范围放成全开。账按文档序排(父条目丢在哪个位置,
428
+ * 体内的子条目就排在哪个位置)。
429
+ *
430
+ * 边界(如实登记):① 只下钻**条目**(集合的元素 / 值),不下钻顶层的普通对象字段(`models.atMention` 不是条目);② raw 不是
423
431
  * 纯 JSON 对象、字段值与 schema 形状对不上(schema 说数组、文档给对象)⇒ 原样返回,交给整域档位裁;
424
432
  * ③ 域级判据(重名 / 悬空 ref)不在本函数视野内 —— 丢条目救不了它们,调用方据此回落整域档位;
425
- * ④ 只认 `ZodArray` / `ZodRecord` 两种条目集合形 —— 别的形(元组等)不抢救,退回整域档位(保守方向)。
433
+ * ④ 只认 `ZodArray` / `ZodRecord` 两种条目集合形 —— 别的形(元组等)不抢救,退回整域档位(保守方向);⑤ 递归深度止于 schema
434
+ * (本包没有自引用 schema);`z.lazy` 包着的 schema 不展开(`unwrapSchema` 不认它)⇒ 不下钻,那一条按原样判。
426
435
  */
427
436
  export function salvageEntries(schema, raw) {
437
+ const layer = salvageLayer(schema, raw);
438
+ return { raw: layer.raw, dropped: layer.dropped, keptIndex: keptIndexOf(layer.shifts) };
439
+ }
440
+ /** 一层抢救,抢不动就当没抢救(反射 / 访问器抛错:绝不外泄其文案)。最外层与每一条条目体内走的是**同一只** —— 一条条目体内抢不动,
441
+ * 只是这一条不下钻、照原样去判,不连坐它所在的那一层。 */
442
+ function salvageLayer(schema, raw) {
428
443
  try {
429
- return salvageEntriesUnsafe(schema, raw);
444
+ return salvageLayerUnsafe(schema, raw);
430
445
  }
431
446
  catch {
432
- return { raw, dropped: [], keptIndex: NO_ENTRIES_DROPPED }; // 反射/访问器抛错:抢不动就当没抢救,绝不外泄其文案
447
+ return { raw, dropped: [], shifts: [] };
448
+ }
449
+ }
450
+ /** 集合字段的值 → 它的位置表;值的形与 schema 对不上(schema 说数组、文档给对象)⇒ `undefined`(不抢救,交给整域档位)。
451
+ * 数组走 `forEach`(不是手写 `for (i < value.length)`):它在**进入时**就钉死了长度,而重读 `value.length` 会跟着一个在校验期间
452
+ * 自我增殖的数组一路跑下去(病态 getter 在 zod 读字段时往同一个数组里再 push 一条)—— 抢救是尽力而为的旁路,绝不许把同步读挂死;
453
+ * 位置表在任何判定之前就收齐了,之后数组再怎么长都与本次抢救无关。`forEach` 跳过空洞,与此前的 `filter` 同。 */
454
+ function collectionSlots(value, isArray) {
455
+ if (isArray) {
456
+ if (!Array.isArray(value))
457
+ return undefined;
458
+ const slots = [];
459
+ value.forEach((entry, i) => void slots.push([i, entry]));
460
+ return slots;
433
461
  }
462
+ return isPlainJsonObject(value) ? Object.entries(value) : undefined;
434
463
  }
435
- function salvageEntriesUnsafe(schema, raw) {
464
+ function salvageLayerUnsafe(schema, raw) {
465
+ const untouched = { raw, dropped: [], shifts: [] };
436
466
  if (!isPlainJsonObject(raw))
437
- return { raw, dropped: [], keptIndex: NO_ENTRIES_DROPPED };
467
+ return untouched;
438
468
  const root = unwrapSchema(schema);
439
469
  if (!(root instanceof z.ZodObject))
440
- return { raw, dropped: [], keptIndex: NO_ENTRIES_DROPPED };
470
+ return untouched;
441
471
  const shape = root.shape;
442
472
  const dropped = [];
473
+ const shifts = [];
443
474
  const patch = {};
444
- const keptIndex = {};
445
475
  for (const [field, fieldSchema] of Object.entries(shape)) {
446
- const value = raw[field];
447
- if (value === undefined)
448
- continue;
449
476
  const collection = unwrapSchema(fieldSchema);
450
- if (collection instanceof z.ZodArray && Array.isArray(value)) {
451
- const element = collection.element;
452
- const bad = [];
453
- const keptFrom = [];
454
- // `filter`(不是手写 `for (i < value.length)`):它在**进入时**就钉死了长度,而重读 `value.length`
455
- // 会跟着一个在校验期间自我增殖的数组一路跑下去(病态 getter 在 zod 读字段时往同一个数组里再 push
456
- // 一条)——抢救是尽力而为的旁路,绝不许把同步读挂死。下标在回调里收,`filter` 本来就给 index。
457
- const kept = value.filter((entry, i) => {
458
- const verdict = judgeEntry(element, entry, "entry-schema-invalid");
459
- if (verdict === undefined) {
460
- keptFrom.push(i); // 压缩后第 keptFrom.length-1 条 = 原文档第 i 条
461
- return true;
462
- }
463
- bad.push(droppedEntry(outgoingPath([field, i]), verdict, entry));
464
- return false;
465
- });
466
- // 零幸存者 ⇒ 没有要抢救的东西(见头注:绝不凭空造出一个空集合)。
467
- if (bad.length > 0 && kept.length > 0) {
468
- dropped.push(...bad);
469
- patch[field] = kept;
470
- keptIndex[field] = keptFrom; // 只在真压缩了的字段上登记(见 {@link EntryKeptIndex})
471
- }
472
- }
473
- else if (collection instanceof z.ZodRecord && isPlainJsonObject(value)) {
474
- const keySchema = collection.keyType;
475
- const valueSchema = collection.valueType;
476
- const bad = [];
477
- const kept = {};
478
- for (const [key, entry] of Object.entries(value)) {
479
- const verdict = judgeEntry(keySchema, key, "entry-key-invalid") ?? judgeEntry(valueSchema, entry, "entry-schema-invalid");
480
- if (verdict === undefined)
481
- kept[key] = entry;
482
- else
483
- bad.push(droppedEntry(outgoingPath([field, key]), verdict, entry)); // 键是用户写的,可能就是一条粘错位置的凭据
477
+ const isArray = collection instanceof z.ZodArray;
478
+ if (!isArray && !(collection instanceof z.ZodRecord))
479
+ continue; // 不是条目集合:连读都不读(少触发一次访问器)
480
+ const slots = collectionSlots(raw[field], isArray);
481
+ if (slots === undefined)
482
+ continue;
483
+ const element = (collection instanceof z.ZodArray ? collection.element : collection.valueType);
484
+ const keySchema = collection instanceof z.ZodRecord ? collection.keyType : undefined;
485
+ const found = []; // 本字段上账的:本层丢的整条 + 幸存条目体内丢的子条目,按文档序
486
+ const survivors = []; // 幸存条目(体内抢救过的,是它的抢救形)
487
+ const innerShifts = [];
488
+ for (const [address, entry] of slots) {
489
+ const at = outgoingPath([field, address]); // 键是用户写的,可能就是一条粘错位置的凭据
490
+ // Record 的键先判:键坏的条目不再算值,也不下钻。
491
+ const keyVerdict = keySchema === undefined ? undefined : judgeEntry(keySchema, address, "entry-key-invalid");
492
+ if (keyVerdict !== undefined) {
493
+ found.push(droppedEntry(at, keyVerdict, entry));
494
+ continue;
484
495
  }
485
- if (bad.length > 0 && Object.keys(kept).length > 0) {
486
- dropped.push(...bad);
487
- patch[field] = kept;
496
+ const inner = salvageLayer(element, entry); // 先下钻:这一条自己的集合按同一条律抢救
497
+ const verdict = judgeEntry(element, inner.raw, "entry-schema-invalid");
498
+ if (verdict !== undefined) {
499
+ // 整条丢 ⇒ 记账行说的是**原文**:身份取原文,判决也取原文的(拒句里的坐标是运维手里那份文档的 —— 抢救形的数组压缩过,
500
+ // 条目级判据点到体内下标时会报错位;坐标律 S-323b 在 `error` 这一面同样成立)。体内的半路账作废。体内真丢过东西的条目,原文必判不过
501
+ // (被丢的子条目本身就过不了它的 schema),`?? verdict` 只为类型。
502
+ found.push(droppedEntry(at, inner.raw === entry ? verdict : (judgeEntry(element, entry, "entry-schema-invalid") ?? verdict), entry));
503
+ continue;
488
504
  }
505
+ survivors.push([address, inner.raw]);
506
+ for (const d of inner.dropped)
507
+ found.push({ ...d, path: [...at, ...d.path] });
508
+ for (const s of inner.shifts)
509
+ innerShifts.push({ at: [...at, ...s.at], kept: s.kept });
489
510
  }
511
+ // 零幸存者 ⇒ 没有要抢救的东西(见头注:绝不凭空造出一个空集合);什么都没上账 ⇒ 这个字段原样不动。
512
+ if (survivors.length === 0 || found.length === 0)
513
+ continue;
514
+ dropped.push(...found);
515
+ // fromEntries(不是逐键赋值):Record 的键是用户写的,`__proto__` 作键时逐键赋值会走原型 setter(S-332 同一条「键就是键」)。
516
+ patch[field] = isArray ? survivors.map(([, e]) => e) : Object.fromEntries(survivors);
517
+ const keptFrom = survivors.map(([i]) => i);
518
+ if (isArray && (keptFrom.length < slots.length || keptFrom.some((original, j) => original !== j)))
519
+ shifts.push({ at: outgoingPath([field]), kept: keptFrom });
520
+ shifts.push(...innerShifts);
490
521
  }
491
522
  if (dropped.length === 0)
492
- return { raw, dropped, keptIndex: NO_ENTRIES_DROPPED };
493
- return { raw: { ...raw, ...patch }, dropped, keptIndex };
523
+ return untouched;
524
+ return { raw: { ...raw, ...patch }, dropped, shifts };
525
+ }
526
+ /** 段坐标记的位移账 → {@link EntryKeptIndex}(键 = 全路径的出门串)。同一个键出现两次只可能是两只被打码成同一串的 Record 键
527
+ * (坐标本身已分不清是谁)⇒ 那个键不登记。什么都没压缩 ⇒ 冻结的共享空表。 */
528
+ function keptIndexOf(shifts) {
529
+ if (shifts.length === 0)
530
+ return NO_ENTRIES_DROPPED;
531
+ const byKey = new Map();
532
+ for (const s of shifts) {
533
+ const key = formatConfigPath(s.at);
534
+ byKey.set(key, byKey.has(key) ? undefined : s.kept);
535
+ }
536
+ // fromEntries:键是出门串,含用户写的 Record 键段 —— 不走原型 setter。
537
+ return Object.fromEntries([...byKey].filter((e) => e[1] !== undefined));
494
538
  }
495
539
  /**
496
540
  * Shared GC predicate for worker-status rows (F10) — keeps memory/sqlite/tidb identical. Given the rows'
@@ -617,30 +661,36 @@ function rebasePaths(keys, keptIndex) {
617
661
  return changed ? out : keys;
618
662
  }
619
663
  /**
620
- * `[field, N, ...rest]` → `[field, 原 N, ...rest]`。**一条律**(S-323c,3.0.0):`p[0]` 是字符串且在位移表里
621
- * (= 那个数组字段真被压缩过)∧ `p[1]` 是**数字段** ⇔ 它是一条数组路径。不满足 ⇒ 原样。
664
+ * 压缩后的坐标 → 原文档坐标。**一条律**(S-323c 3.0.0 立,12.3.0 S-1494 推到每一层):坐标里的每个**数字段** `p[i]`,若它前面那段
665
+ * 前缀(已回写成原文档坐标、按 {@link formatConfigPath} 出门形)在位移表里 ⇔ 它是那个被压缩过的数组的下标 ⇒ 换成原下标。
666
+ * 从左往右逐段做:外层先回写,内层的前缀才是原文档坐标,才对得上表里的键(位移表的键都是原文档全路径)—— 两层都压缩过时
667
+ * 两层各回写一次,只回写最长前缀那一层会把外层的下标留在压缩坐标上。
622
668
  *
623
669
  * 2.1.0 在点号串上需要三道门(字段在表里 / 段数合它那一支文法 / 下标段是规范十进制),因为串把「这一段
624
670
  * 是下标还是键名」这个位丢了,只能靠文法猜回来;段形把那个位带在类型里,三道门收成一道 —— 判它是正确
625
671
  * 抽象的第一判据就是**规则数变少**(`PathGrammar`、段数门、规范十进制正则三件随之删除)。
626
672
  *
627
- * 字面含点的顶层键(`["models.1.k"]`)天然不受影响:它只有一段,`p[1]` 缺席。Record 形的条目坐标
628
- * (`[field, key]`)同样:Record 字段从不进位移表(条目按键寻址,丢一条不动其余键的坐标),而它的第二段
629
- * 是字符串键、不是下标 —— 两道判据各自都挡得住。
673
+ * 字面含点的顶层键(`["models.1.k"]`)天然不受影响:它只有一段,没有数字段。Record 形的条目坐标
674
+ * (`[field, key]`)同样:Record 从不进位移表(条目按键寻址,丢一条不动其余键的坐标),而它的那一段
675
+ * 是字符串键、不是下标 —— 两道判据各自都挡得住。首段不判(域文档的根是对象,首段恒是字段名)。
630
676
  *
631
677
  * **恒等纪律**:没有任何一段真的变了(表长外、或原下标与压缩后下标相同)⇒ 返回**原引用**,不复制。
632
678
  */
633
679
  function rebasePath(p, keptIndex) {
634
- const field = p[0];
635
- if (typeof field !== "string" || !Object.prototype.hasOwnProperty.call(keptIndex, field))
636
- return p;
637
- const index = typeof p[1] === "number" ? p[1] : undefined;
638
- if (index === undefined)
639
- return p;
640
- const original = keptIndex[field][index];
641
- if (original === undefined || original === index)
642
- return p; // 表长外 ⇒ 原样(与 2.1.0「越界原样」同律)
643
- return [field, original, ...p.slice(2)];
680
+ let out;
681
+ for (let i = 1; i < p.length; i++) {
682
+ const seg = p[i];
683
+ if (typeof seg !== "number")
684
+ continue;
685
+ const at = formatConfigPath((out ?? p).slice(0, i));
686
+ if (!Object.prototype.hasOwnProperty.call(keptIndex, at))
687
+ continue;
688
+ const original = keptIndex[at][seg];
689
+ if (original === undefined || original === seg)
690
+ continue; // 表长外 ⇒ 原样(与 2.1.0「越界原样」同律)
691
+ (out ??= [...p])[i] = original;
692
+ }
693
+ return out ?? p;
644
694
  }
645
695
  /** 扫描深度上限(嵌套再深的配置文档不存在;有上限才不会被恶意深链拖住)。 */
646
696
  const DROP_SCAN_MAX_DEPTH = 8;
@@ -796,23 +846,90 @@ function carriedUnknownKeys(domain, parsed) {
796
846
  return acc;
797
847
  }
798
848
  /**
799
- * 一域的 parse + **静默剥键/静默承运两条告警**(0.19.0)。`buildEffective` 与 `FileConfigStore.getDomain`
800
- * 共用这一个判据 —— 此前只有 effective 那条路径有门,而 `getDomain` 是 `exportBundle` 的读法
849
+ * **文档面**的域读函数:一域的严 parse + **静默剥键/静默承运两条告警**,**永不抢救**。`FileConfigStore.getDomain`(于是 `exportBundle`、
850
+ * `createSiblingResolver`)与一致性门写入席(`strictWrite` 腿)读它 —— 此前只有 effective 那条路径有门,而 `getDomain` 是 `exportBundle` 的读法
801
851
  * (对抗复审 复审 round2 F3:导出一份被剥了键的域再导入 = 持久的配置丢失,而且全程无声)。
852
+ *
853
+ * 文档面回答「这份文档本身读不读得进」:读不进就抛(sanitized ZodError),一条条目都不替运维删 —— 写入席、导出、读改写的「现状」读的都是它。
854
+ * 视图面(「这份文档在生效配置里是什么」)是 {@link parseDomainSalvaged}:同一只内部体({@link readDomainDocument}),读不进时多一步逐条抢救。
802
855
  */
803
856
  export function parseDomainLoud(domain, raw, onWarning) {
804
- const parsed = parseDomain(domain, raw);
857
+ return readDomainDocument(domain, raw, onWarning, (unreadable) => {
858
+ throw unreadable;
859
+ });
860
+ }
861
+ /**
862
+ * **视图面**的域读函数(12.3.0,S-1494):与 {@link parseDomainLoud} 同一只内部体,整域读不进时按 {@link DOMAIN_ENTRY_SALVAGE} 逐条抢救。
863
+ * `buildEffective` 十六个域一处调它(`parseOr`);管理面要显示「生效配置里这个域是什么」时读它。
864
+ *
865
+ * 读法:
866
+ * 1. 整域 `parseDomain`;过 ⇒ 未知键两条告警按原文档坐标出门,返回 —— 与 {@link parseDomainLoud} 逐字同值同告警(好配置的路径一字不动)。
867
+ * 2. 不过,且 {@link DOMAIN_ENTRY_SALVAGE} 判该域 `"salvage"` ⇒ {@link salvageEntries}(递归到嵌套集合);丢了东西 ⇒ 拿压缩后的
868
+ * 文档再 `parseDomain` 一次。过 ⇒ 每条被丢条目一条 `entry-dropped`(它们本来就是原文档坐标),其余告警是对压缩文档的诊断,
869
+ * 过本层位移表({@link rebaseWarningPaths})回写成原文档坐标(坐标律 S-323b)。
870
+ * 3. 其余一切(`"none"` 档、什么都没丢、丢了仍读不进)⇒ **抛第 1 步的原错**,零告警 —— 一个域一个结论:没救活就不留「丢了某条」的半路叙述。
871
+ * 整域读不进之后是默认化还是抛,是调用方按 {@link DOMAIN_READ_FALLBACK} 的事(`buildEffective` 的 `parseOr`),本函数不读那张表。
872
+ *
873
+ * 写路径与导出面不调它:`parseDomain` / `validateDomain` / `writeDomainFile` 判整份文档,写入席与 `getDomain` / `exportBundle` 读 {@link parseDomainLoud}。
874
+ * 读改写(读存量、改、整份写回)的「现状」也不许读它 —— 抢救读出再写回,被丢的条目就成了删除。
875
+ */
876
+ export function parseDomainSalvaged(domain, raw, onWarning) {
877
+ return readDomainDocument(domain, raw, onWarning, (unreadable) => parseSalvaged(domain, raw, unreadable, onWarning));
878
+ }
879
+ /**
880
+ * 两面共用的**唯一内部体**:严 parse(`parseDomain`)+ 未知键两条告警。整域读不进时交给 `whenUnreadable`(文档面:抛原错;视图面:逐条抢救)——
881
+ * 两个读函数只差这一个入参。告警在 parse 成功之后、`try` 之外发:`onWarning` 自己抛的错不会被当成「文档读不进」。
882
+ */
883
+ function readDomainDocument(domain, raw, onWarning, whenUnreadable) {
884
+ let parsed;
885
+ try {
886
+ parsed = parseDomain(domain, raw);
887
+ }
888
+ catch (unreadable) {
889
+ return whenUnreadable(unreadable);
890
+ }
891
+ if (onWarning)
892
+ announceUnknownKeys(domain, raw, parsed, onWarning);
893
+ return parsed;
894
+ }
895
+ /** {@link parseDomainSalvaged} 的第 2 / 3 步(整域 parse 已经失败):能救就救,救不了就抛 `unreadable`(第 1 步的原错)。
896
+ * 档位判 `!== "salvage"`(不是 `=== "none"`):一个不是域的名字(JS 调用方 / 写入席收到的任意键)查表得到 `undefined` 或原型链上的东西,
897
+ * 照样不抢救 —— 只有表里白纸黑字的 `"salvage"` 才开这扇门。 */
898
+ function parseSalvaged(domain, raw, unreadable, onWarning) {
899
+ if (DOMAIN_ENTRY_SALVAGE[domain] !== "salvage")
900
+ throw unreadable;
901
+ const salvage = salvageEntries(DOMAIN_SCHEMAS[domain], raw);
902
+ if (salvage.dropped.length === 0)
903
+ throw unreadable;
904
+ let parsed;
905
+ try {
906
+ parsed = parseDomain(domain, salvage.raw);
907
+ }
908
+ catch {
909
+ throw unreadable; // 条目之外还有毛病(重名 / 悬空 ref / 域级判据)—— 丢条目救不了它
910
+ }
805
911
  if (onWarning) {
806
- const dropped = droppedKeys(domain, raw, parsed);
807
- if (dropped.keys.length > 0)
808
- onWarning({ domain, kind: "unknown-keys-dropped", keys: dropped.keys, ...(dropped.total > dropped.keys.length ? { truncatedFrom: dropped.total } : {}) });
809
- const carried = carriedUnknownKeys(domain, parsed);
810
- if (carried.keys.length > 0)
811
- onWarning({ domain, kind: "unknown-keys-carried", keys: carried.keys, ...(carried.total > carried.keys.length ? { truncatedFrom: carried.total } : {}) });
912
+ // 坐标律(S-323b):一条告警用「铸它的那一层所见文档」的坐标表达;哪一层压缩了文档,哪一层就把从它下方上来的告警回写一次。
913
+ // `entry-dropped` 是本层(salvage)铸的,用的就是本层入参那份文档的坐标 ⇒ 原样出门;未知键扫描是对本层**压缩后**文档的诊断 ⇒ 过本层的位移表。
914
+ for (const d of salvage.dropped)
915
+ onWarning(entryDroppedWarning(domain, d));
916
+ announceUnknownKeys(domain, salvage.raw, parsed, (w) => onWarning(rebaseWarningPaths(w, salvage.keptIndex)));
812
917
  }
813
918
  return parsed;
814
919
  }
920
+ /** 未知键两条告警(剥掉的 / 承运的)—— `raw` 是这次真被 parse 的那份文档,`emit` 决定坐标出门前要不要回写。 */
921
+ function announceUnknownKeys(domain, raw, parsed, emit) {
922
+ const dropped = droppedKeys(domain, raw, parsed);
923
+ if (dropped.keys.length > 0)
924
+ emit({ domain, kind: "unknown-keys-dropped", keys: dropped.keys, ...(dropped.total > dropped.keys.length ? { truncatedFrom: dropped.total } : {}) });
925
+ const carried = carriedUnknownKeys(domain, parsed);
926
+ if (carried.keys.length > 0)
927
+ emit({ domain, kind: "unknown-keys-carried", keys: carried.keys, ...(carried.total > carried.keys.length ? { truncatedFrom: carried.total } : {}) });
928
+ }
815
929
  /** Per-domain READ-corruption policy (0.8.1) — what buildEffective does when a stored domain no longer parses.
930
+ * **One question only** (12.3.0, S-1494): the domain as a whole is unreadable — after {@link parseDomainSalvaged} has tried
931
+ * whatever entry salvage {@link DOMAIN_ENTRY_SALVAGE} allows — so does the read default it or throw? Whether single bad
932
+ * entries may be dropped is the OTHER table's question; neither table reads the other.
816
933
  * `"default"` = degrade to the schema default + warn (safe: the default only REMOVES capability, fail-closed).
817
934
  * `"throw"` = keep fail-loud (defaulting would LOOSEN gates or destructively apply — worse than an outage,
818
935
  * consumers fail-static on last-good). A `satisfies Record<DomainName,…>` forces every FUTURE domain to make
@@ -861,6 +978,55 @@ export const DOMAIN_READ_FALLBACK = {
861
978
  // 消费方 fail-static 在上一份好配置严格更好。
862
979
  limits: "throw",
863
980
  };
981
+ /** Per-domain ENTRY-SALVAGE policy (12.3.0, S-1494) — may the view-face domain reader ({@link parseDomainSalvaged}) drop single bad
982
+ * entries (an array element, a Record value, and — recursively — a bad entry inside a surviving entry) and read the rest?
983
+ * **One question only**: what happens when the domain stays unreadable after that is {@link DOMAIN_READ_FALLBACK}'s question.
984
+ * `"salvage"` = drop each bad entry, name it (`entry-dropped`, coordinate + identity + reason word), keep the rest.
985
+ * `"none"` = never drop a single entry: the first bad entry makes the whole domain unreadable.
986
+ * A `satisfies Record<DomainName,…>` forces every FUTURE domain to answer this explicitly (compile error until classified).
987
+ *
988
+ * **View face only.** The document face never salvages: `parseDomain` / `validateDomain` / `writeDomainFile` judge the whole
989
+ * document, and {@link parseDomainLoud} — what the conformance write seat (`strictWrite` leg), `FileConfigStore.getDomain` and
990
+ * `exportBundle` read — throws on it. A write path that reads the stored "current" value through `parseDomainSalvaged` and writes
991
+ * it back would turn every dropped entry into a deletion — read-modify-write faces read the stored document with the document face.
992
+ *
993
+ * Per-domain rationale:
994
+ * - models/skills/mcp/plugins/scenarios/systems/collab/prompts: additive capability / content catalogs — a dropped entry is
995
+ * one capability gone (fail-closed). Inside a surviving entry the same holds: the nested collections these entries carry
996
+ * are allowlists / scopings (`allowTools`, `scenarios`, `rosters`, `callsWorkers`, …) where one entry fewer only NARROWS,
997
+ * and the zero-survivor law (never mint an empty collection — several of these read "empty = all") holds at every level.
998
+ * - hosts: a dropped host is one placement target fewer; a dropped label makes a host match fewer `placement.requires`.
999
+ * - projects: SALVAGE — a registration absent from the ledger = that project's claim is refused (fail-closed). Throwing the
1000
+ * whole domain stops EVERY project of the scope, which protects nothing: one stored registration that a later, tighter
1001
+ * schema refuses (12.2.1's UNC judgement on `contextItems[].value`) must not take the scope's other projects with it.
1002
+ * Its whenUnreadable stance stays `"throw"` (a ledger that cannot be read at all is never defaulted — see
1003
+ * {@link DOMAIN_READ_FALLBACK}).
1004
+ * - rosters: NONE — a dropped roster makes resolveEffectiveForWorker fall back to the FULL models catalog for that worker
1005
+ * (放权,不许).
1006
+ * - workers: NONE — a dropped worker row reads as "this worker is no longer declared": the reconciler would retire it. The
1007
+ * per-row tolerance this domain does have lives in `readWorkersRows` (rows are HELD with their original, never dropped).
1008
+ * - governance: NONE — a dropped commandPolicy / approvalRequire row is a guardrail silently gone (放权,不许).
1009
+ * - entitlement: NONE — a dropped tier / subject / binding / budget group changes who may do what (放权,不许).
1010
+ * - execution: NONE — a dropped lane / public key changes the execution mandate (放权,不许).
1011
+ * - limits: NONE — a dropped allowlist row changes a published ceiling (the domain is the one publishing seat of those ceilings). */
1012
+ export const DOMAIN_ENTRY_SALVAGE = {
1013
+ models: "salvage",
1014
+ rosters: "none",
1015
+ skills: "salvage",
1016
+ mcp: "salvage",
1017
+ plugins: "salvage",
1018
+ scenarios: "salvage",
1019
+ systems: "salvage",
1020
+ collab: "salvage",
1021
+ workers: "none",
1022
+ hosts: "salvage",
1023
+ governance: "none",
1024
+ entitlement: "none",
1025
+ execution: "none",
1026
+ projects: "salvage",
1027
+ prompts: "salvage",
1028
+ limits: "none",
1029
+ };
864
1030
  /**
865
1031
  * Merge every domain into the effective config a consumer pulls. Shared by all store impls.
866
1032
  * `updatedAt` is REQUIRED (the determinism refactor — the original store.ts defaulted it to the wall clock,
@@ -873,41 +1039,17 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
873
1039
  // 墓碑先判:一份没迁的retired 域文档意味着这个 scope 的存量还没过一次性迁移 —— 拿剩下的域拼一份 effective 等于静默丢掉它的事实。
874
1040
  for (const retired of Object.keys(RETIRED_DOMAINS))
875
1041
  refuseRetiredDomain(retired, getRaw(retired));
876
- // 0.19.0 静默剥键/静默承运门:解析**成功**的域,把 schema 丢掉的键、以及 open-world 域里承运但本包
877
- // 不认识的键(都含嵌套,段坐标)点名(行为不变,只是不再无声)。判据与 `getDomain` 同源
878
- // ({@link parseDomainLoud})。只在成功路径上报——降级路径已经有 domain-defaulted 那条更重的警告。
879
- const parseLoud = (domain, raw) => parseDomainLoud(domain, raw, onWarning);
880
- // Generic READ guardrail (0.8.1): a domain that no longer parses degrades to its schema default + a warning
881
- // — but ONLY where DOMAIN_READ_FALLBACK says the default is safe; gate-bearing domains keep throwing (决策
882
- // 表与逐域理由见 DOMAIN_READ_FALLBACK). The success path is UNCHANGED (same parseDomain, same bytes).
1042
+ // 每个域同一只读法(12.3.0,S-1494):视图面读函数 {@link parseDomainSalvaged}(整域 parse → 按 DOMAIN_ENTRY_SALVAGE 逐条抢救 → 未知键告警;
1043
+ // 与文档面 `parseDomainLoud` 共用同一只内部体)读不进 ⇒ 只剩**整域读不进**这一个问题,答案只看 DOMAIN_READ_FALLBACK(决策表与逐域理由见那张表):
1044
+ // `"throw"` ⇒ 原错抛出;`"default"` ⇒ schema 默认 + `domain-defaulted`。哪个域走哪一档不再写在下面的行上(此前 gate 域的行
1045
+ // 调 parseLoud、其余调 parseOr —— 同一张表的第二份手抄),行只说「读这个域」。好配置的路径一字不动(同一次 parseDomain,同一份字节)。
883
1046
  const parseOr = (domain, raw) => {
884
1047
  try {
885
- return parseDomainLoud(domain, raw, onWarning);
1048
+ return parseDomainSalvaged(domain, raw, onWarning);
886
1049
  }
887
1050
  catch (e) {
888
1051
  if (DOMAIN_READ_FALLBACK[domain] === "throw")
889
1052
  throw e;
890
- // 逐条容错(2.0.0)在整域降级**之前**试一次:只丢掉真正坏掉的那几条条目,同域好兄弟活下来。
891
- // 告警先缓存再决定 —— 一个域只给一个结论:救活了就逐条报 `entry-dropped`,没救活就只报
892
- // `domain-defaulted`(那一域的条目反正一条都不剩,再讲「丢了第 3 条」只会误导)。
893
- const salvage = salvageEntries(DOMAIN_SCHEMAS[domain], raw);
894
- if (salvage.dropped.length > 0) {
895
- try {
896
- const buffered = [];
897
- const parsed = parseDomainLoud(domain, salvage.raw, (w) => buffered.push(w));
898
- // 坐标律(S-323b):一条告警用「铸它的那一层所见文档」的坐标表达;哪一层压缩了文档,哪一层就把从它下方上来的告警回写一次。
899
- // `entry-dropped` 是本层(salvage)铸的,用的就是本层入参那份文档的坐标 ⇒ 原样出门;`buffered` 里的是对本层**压缩后**
900
- // 文档的诊断 ⇒ 先过本层的位移表。读路径上只有这一个压缩点(11.0.0 起 hosts 的读侧夹紧臂已retired)。
901
- for (const d of salvage.dropped)
902
- onWarning?.(entryDroppedWarning(domain, d));
903
- for (const w of buffered)
904
- onWarning?.(rebaseWarningPaths(w, salvage.keptIndex));
905
- return parsed;
906
- }
907
- catch {
908
- // 条目之外还有毛病(重名 / 悬空 ref / 域级判据)—— 丢条目救不了它,照常整域降级。
909
- }
910
- }
911
1053
  onWarning?.({ domain, kind: "domain-defaulted", error: e });
912
1054
  return parseDomain(domain, undefined); // schema default — always valid
913
1055
  }
@@ -916,30 +1058,26 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
916
1058
  version,
917
1059
  updatedAt,
918
1060
  models: parseOr("models", getRaw("models")),
919
- rosters: parseLoud("rosters", getRaw("rosters")),
1061
+ rosters: parseOr("rosters", getRaw("rosters")),
920
1062
  skills: parseOr("skills", getRaw("skills")),
921
1063
  mcp: parseOr("mcp", getRaw("mcp")),
922
1064
  plugins: parseOr("plugins", getRaw("plugins")),
923
1065
  scenarios: parseOr("scenarios", getRaw("scenarios")),
924
1066
  systems: parseOr("systems", getRaw("systems")),
925
1067
  collab: parseOr("collab", getRaw("collab")),
926
- workers: parseLoud("workers", getRaw("workers")),
1068
+ workers: parseOr("workers", getRaw("workers")),
927
1069
  hosts: parseOr("hosts", getRaw("hosts")),
928
1070
  // governance TRUTH = the domain itself (11.0.0: the read-side lift from a 0.5-era runtime document is gone — the
929
1071
  // runtime tombstone above refuses such a document, and the one-time migration SS-M1 moves its trio here).
930
- governance: parseLoud("governance", getRaw("governance")),
931
- entitlement: parseLoud("entitlement", getRaw("entitlement")),
1072
+ governance: parseOr("governance", getRaw("governance")),
1073
+ entitlement: parseOr("entitlement", getRaw("entitlement")),
932
1074
  // execution (0.8.0, S3): plain scope-level parse — MIRRORS governance (no overlay/merge invented here).
933
- execution: parseLoud("execution", getRaw("execution")),
934
- // projects (0.10.0, 142-S3): THROW policy → parseLoud(≡ parseDomain + 剥键告警), never parseOr
935
- // (defaulting would evaporate the identity ledger AND expose the claim read-modify-write face to
936
- // clobbering stored registrations).
937
- projects: parseLoud("projects", getRaw("projects")),
938
- // prompts (0.10.18, [1064]②): content domain → parseOr(坏形回空默认=「无 prompts 管理」语义;
939
- // fail-closed 由 PUBLISH_MODE 发布闸承担,空 bindings 不下发内容,default 不扩权)。
1075
+ execution: parseOr("execution", getRaw("execution")),
1076
+ projects: parseOr("projects", getRaw("projects")),
1077
+ // prompts (0.10.18, [1064]②): content domain(坏形回空默认=「无 prompts 管理」语义;fail-closed 由 PUBLISH_MODE 发布闸承担,
1078
+ // 空 bindings 不下发内容,default 不扩权)。
940
1079
  prompts: parseOr("prompts", getRaw("prompts")),
941
- // limits (0.19.0, 消费方 #322 批1): THROW 档 → parseLoud(坏形绝不静默降级成「没有天花板」)。
942
- limits: parseLoud("limits", getRaw("limits")),
1080
+ limits: parseOr("limits", getRaw("limits")),
943
1081
  };
944
1082
  }
945
1083
  /** READ-side tolerant judgment for a GET /effective **wire payload**(the {@link EffectiveWire} contract)at
package/dist/host-path.js CHANGED
@@ -15,7 +15,8 @@ export const ABSOLUTE_HOST_PATH_MAX = 4096;
15
15
  * 读方只拿「形合法」这一个事实,怎么解析归宿主那一族 —— 这里不碰文件系统、不做族内归一。
16
16
  */
17
17
  const WIN_DRIVE_ABSOLUTE = /^[A-Za-z]:[\\/]/;
18
- const WIN_UNC_ABSOLUTE = /^\\\\[^\\/?*]+[\\/][^\\/]+/;
18
+ // UNC 首段是主机名(字母数字起头,其后字母数字 . _ -):Windows 设备命名空间前缀 `\\.\` 与长路径前缀 `\\?\` 都不是主机路径,一律拒。
19
+ const WIN_UNC_ABSOLUTE = /^\\\\[A-Za-z0-9][A-Za-z0-9._-]*[\\/][^\\/]+/;
19
20
  export function isAbsoluteHostPath(p) {
20
21
  return (typeof p === "string" && p.length > 0 && p.length <= ABSOLUTE_HOST_PATH_MAX && !p.includes("\0") &&
21
22
  (p.startsWith("/") || WIN_DRIVE_ABSOLUTE.test(p) || WIN_UNC_ABSOLUTE.test(p)) &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/settings-schema",
3
- "version": "12.2.0",
3
+ "version": "12.3.0",
4
4
  "description": "Sema settings schema — pure config contract (zod domains + effective-config + roster resolution + ref-integrity + remoteExec) shared by sema-web-admin, 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",