@sema-agent/settings-schema 11.3.0 → 12.0.1

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,84 @@
6
6
  > `@sema-agent/registry-core`(更早还曾是 `@sema-ai/registry-core`)。本档 `0.19.0` 以下各节里的
7
7
  > 包名是**历史记录**,如实保留不追改。
8
8
 
9
+ ## 12.0.1 — 2026-10-12 — patch:文件系统失败句一只铸点(errno × 落点 × 真实路径)覆盖写 / 删 / 改名 / 迁移拒句 —— 删除失败点名目录 · 拒句用真实路径不写死 config.d/ · 不可变目录提示 · ENAMETOOLONG 一句;零型面
10
+
11
+ > patch:文件系统失败句收成一只铸点,覆盖写 / 删 / 改名 / 读与 `config.d` 文档拒句四处。零型面(零新导出、零删、零改名;导出面登记表不动),退出码、报告行、库写口抛的错的 `code` / `cause` 都不变;
12
+ > 变的只是 stderr / 错误 `message` 里的句子文本,逐条列在下面。消费方:零改动(按 `code` 分支的调用方不受影响;逐字匹配这些句子的脚本按新读数改匹配)。
13
+
14
+ ### 行为修复:文件系统失败句只有一只铸点 —— 删除失败点名目录 · 拒句点名真实路径 · 不可变目录提示 · 路径过长一句
15
+
16
+ 12.0.0 只把写失败一臂按 errno 落点分了句,同一类事实在别处还是各自手写的话(删文件、读目录 / 文件、`config.d` 文档拒句、`ENAMETOOLONG`)。
17
+ 现在本包把文件系统错误说成人话只有一只铸点(错误码 × 落点 × 真实路径):落点是闭集(建临时文件 / `rename` / 删 / 读,外加写口整体失败这一档 —— 注入的写口抛的错,看不见是哪一步),
18
+ 原子写器、命令行写 / 删口、目录迁移读口全从这里出句;包内门看住「铸点之外没有第二处手写句」。四处读数:
19
+ - **删除失败**(`migrate` 删 retired 域文件 `runtime.json` 时目录不可写):`cannot remove <文件> from directory <目录> (<errno>): make the directory writable`。
20
+ 以前 `cannot remove <文件>: <errno>: <Node 句>`,点名的是文件,真因是目录(与 12.0.0 修掉的写失败同一形)。
21
+ - **文档拒句点名读的那份真实路径**:`config.d` 里空 / 纯空白 / 坏 JSON / 不是对象(域文档、retired 域文档、`remote-exec.json` 同一只文本判)的三句,
22
+ 以前一律写死 `config.d/<名>.json` 字面 —— 对两个 scope 目录各迁一遍时,两份拒句逐字相同、认不出是哪个目录。现在是 `<目录>/remote-exec.json is empty/whitespace — …`、
23
+ `invalid JSON in <目录>/models.json — …`、`<路径> must be a JSON object (got <形>) — …`;`FileConfigStore` / `readBundle` / `loadRemoteExec` / 命令行各点名自己读的那一份。
24
+ 读不了目录 / 文件的句子同样点名真实路径(以前读文件只给裸文件名)。
25
+ - **目录不可变 / 只读**:建临时文件与删目录项的 `EPERM` / `EROFS`,出路句尾加 `directory may be read-only or immutable (chflags / chattr)`
26
+ (目录带 `chflags uchg` / `chattr +i` 时,只改权限位修不好);`EACCES` / `ENOENT` 的 12.0.0 读数逐字不变。
27
+ - **路径过长**(`ENAMETOOLONG`):一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`。
28
+ 以前落在建临时文件上时读成 `cannot write into directory <目录> (ENAMETOOLONG): ENAMETOOLONG: name too long, …` —— errno 两遍,而且怪到目录头上。
29
+ 「errno 只打一遍」对所有落点生效:别的 errno 的 Node 句子去掉自带的 `<errno>: ` 前缀(例:`cannot replace <文件> (ENOSPC): no space left on device, …`)。
30
+
31
+ 库写口(`writeDomainFile` / `writeRemoteExec` / 调度店写口)抛的错:`message` 是同一只铸点的句子,`code` 照抄底层 errno、原错误在 `cause`,不变。
32
+
33
+ ## 12.0.0 — 2026-10-12 — 角色词随引擎收缩(`ModelRole` 8 → 6,一次性迁移 SS-M8)· 写失败按 errno 落点分句 · `config.d` 每份文档同一只文本判 · 扣名条目的可比记号 `identity.token`(major)
34
+
35
+ > 本节一处硬破坏(角色词表收缩,持久面走一次性迁移 SS-M8)外加三件:命令行写失败按落点分句、`config.d` 每份文档同一只文本判(`remote-exec.json` 非对象不再被 `migrate`
36
+ > 当成无事可做)、扣名条目带可比记号(新导出 `withheldNameToken`,additive)。车道表的租户依据列 `tenantSeparationBasis` 11.0.0 起已是数据,本版无改动。
37
+ > 消费方:server 与引擎 core 7.49.0 同批提货(读点见各条);web-admin 的角色编辑面读 `ModelRole.options`,随表收缩、零改动。版号日期与节标题由发版方写。
38
+
39
+ ### BREAKING:`ModelRole` 收成 6 词 —— 删 `team` / `synthesize`(随引擎 core 7.49.0)
40
+
41
+ - **删什么**:`ModelRole` = `default` / `summarize` / `subagent` / `verifier` / `classifier` / `consolidate`;`team` / `synthesize` 直接删,零别名、零双读。
42
+ `ROSTER_PRIMARY_ROLES` = `["default"]`:roster 的 `primaryModel` 标记只派生 `roles.default`(`ROSTER_CHEAP_ROLES` 不变)。`advisor` 本包从未收。
43
+ - **谁会红**:把 `"team"` / `"synthesize"` 当 `ModelRole` 写的代码 tsc 红;存着 `roles.team` / `roles.synthesize`(`models.roles` 或任一 `rosters[].roles`)的文档
44
+ 在迁移之前到达判官 —— 写侧拒;读侧丢那一条角色(models 里只剩它时整域降级到缺省;rosters 是闸域,整个 scope 读不出)—— 拒句点名一次性迁移 SS-M8。
45
+ - **怎么办**:起服 / 提货前跑 SS-M8(函数 `migrateStoredDocuments`,或 `npx @sema-agent/settings-schema migrate <config.d directory>`),把读 `team` / `synthesize`
46
+ 角色的地方改成读 `default` 或删掉。
47
+
48
+ | 迁移 | 改写什么 | 收据 | 删码之后谁接住 |
49
+ |---|---|---|---|
50
+ | SS-M8 | `models.roles` 与每个 `rosters[].roles` 上的 `team` / `synthesize` 键删掉(值不论);别的角色与别的键原样 | 逐坐标一张 `dropped-inert`(`roles.team` / `rosters.<i>.roles.synthesize` …);再跑零收据 | 退役角色词墓碑(永久):残留的键读写两面拒 |
51
+
52
+ 窗与删法照例只登记在 `ONE_TIME_MIGRATIONS`。收据的迁移 id 闭集多一个 `SS-M8`(`ONE_TIME_MIGRATION_IDS`)。
53
+ - **谁要改**:server 按 `ROSTER_PRIMARY_ROLES` 给每个词写角色的那一处随表只剩 `default`;读 `team` / `synthesize` 角色的消费点(团队 / 合议 / 协作类能力)改读 `default`
54
+ 或随引擎退役;起服对本地 `config.d` 的迁移与提货前对存量文档跑一次 SS-M8。
55
+
56
+ ### 行为修复:命令行写失败按 errno 落点分句
57
+
58
+ `migrate` 写回阶段写盘失败以前一句话包打天下:「cannot write <文件>: <Node 的错误句> … fix the cause and re-run」—— 目录不可写时点名的却是文件(Node 句子里是临时文件的路径),
59
+ 而可写目录里的只读文件照常迁;「fix the cause」也没说是哪个。现在原子写器按失败的那一步出句子,句尾是对应出路:
60
+ - 临时文件建不出(目录不可写:`EACCES` / `EPERM` / `EROFS` / `ENOENT`)⇒ `cannot write into directory <dir> (<errno>): make the directory writable`;
61
+ - `rename` 被拒(`EACCES` / `EPERM`:不可变标志、粘滞目录里别人的文件)⇒ `cannot replace <file> (<errno>): make the file replaceable — …`;
62
+ - 别的 errno(磁盘满、I/O 错 …)⇒ 同两种开头,句尾是 Node 自己的句子。
63
+ 命令行 stderr 一行 = `settings-schema: <那一句> — files written before it keep their migrated form; re-run once that is fixed`,rc 2 不变。
64
+ 库写口(`writeDomainFile` / `writeRemoteExec` / 调度店写口)抛的是同一句的错误:`code` 照抄底层 errno、原错误在 `cause`(按 `code` 分支的调用方不受影响)。
65
+
66
+ ### 行为修复:`config.d` 每份文档同一只文本判 —— `remote-exec.json` 非对象不再被 `migrate` 当成「无事可做」
67
+
68
+ 同一条 `migrate`,`models.json` 写成 `[]` ⇒ rc 2,`remote-exec.json` 写成 `[]` / `null` / `5` / `"x"` / `true` ⇒ noop rc 0、文件不动(它走的是另一只只拒空与坏 JSON 的文本判)。
69
+ 现在 `config.d` 里每份文档(域文档、退役域文档、`remote-exec.json`)过同一只文本判,三种坏形每种一句、点名 `config.d/<名>.json` 与形,句尾出路按文档类:
70
+ - 空 / 纯空白 ⇒ `config.d/<名>.json is empty/whitespace — <出路>`;坏 JSON ⇒ `invalid JSON in config.d/<名>.json — <出路>`;不是对象 ⇒ `config.d/<名>.json must be a JSON object (got <形>) — <出路>`;
71
+ - 出路:域 ⇒ `delete it for the domain default, or write a valid object`;`remote-exec` ⇒ `delete it for the host default, or write a valid remoteExec spec`。
72
+ `FileConfigStore` / `readBundle` / `loadRemoteExec` / 命令行读同一只。措辞随之归一的两处:域文档的坏 JSON 句补上出路;非对象句「delete the file」改成「delete it」(与空文件句同)。
73
+ `loadRemoteExec` 的三句改点名 `config.d/remote-exec.json`(不再是绝对路径),非对象改为上面那句(以前是联合判的 zod 句)—— 抛不抛都不变。
74
+ - **订正 11.3.0 节**:那一节「行为变(命令行)」第二句说「`remote-exec.json` 是空 / 坏 JSON 时 … ⇒ 读不进 rc 2」—— 在 11.3.0 上只对空与坏 JSON 成立,
75
+ 非对象(数组 / `null` / 数 / 串 / 布尔)仍是 noop rc 0。从本版起五种形一律 rc 2,那句话才完整为真。
76
+
77
+ ### 新导出:`withheldNameToken` —— 扣名条目带可比记号 `identity.token`(additive)
78
+
79
+ 被丢条目的名不能原样出门(秘密形 / 超长 / 字符集外)时,身份以前只带 `nameWithheld: true`:两条扣名行在消费方眼里一模一样,「请求点名的那个模型是不是这条坏行」也无从比对。
80
+ 现在扣名的同时带 `token` = `withheld:` + SHA-256(域分隔前缀 + 原名的 JSON 串形 —— 逐个 UTF-16 码元保留,孤立代理项不会与 U+FFFD 撞成同一输入)的前 12 个十六进制位 ——
81
+ 稳定(同名恒同记号)、不可逆、原名一个字节都不出门;
82
+ `DroppedEntryIdentity` 加可选键 `token`(与 `nameWithheld` 同在同缺)。唯一铸法 `withheldNameToken(name)`(主桶与 `./config-fns` 各 +1)。
83
+ 身份只回答「丢的是谁」:在场与否是消费方按原文判的事(注释里「没有名的行不是在场条目」一句删去,与消费方更严的读法一致)。
84
+ 一致性语料里带扣名身份的 6 条 case,金样随之多 `token` 一键。
85
+ - **谁要改**:server 读被丢条目身份的那一处,对请求里显式点名的模型调 `withheldNameToken` 与扣名行的 `token` 比对 —— 命中即「点名的是一条坏行」(而不是「未知模型」);不另写摘要。
86
+
9
87
  ## 11.3.0 — 2026-10-11 — 六件 additive:运行期成员表三张(AUTONOMY_MODES / COMMAND_DECISIONS / PROMPT_SLOTS)· 项目登记扩形(goal / contextItems / archivedAt / icon / color,displayName 上界 200)+ PATCH 形 · 命令行环境错一臂(I/O 错 rc 2 一句点名路径)· 一次性迁移 SS-M7 走到 remote-exec.json · 每条自铸 custom issue 带结构化句子(QuotedIssueParams)· hookMatcherForm(minor:新导出主桶 19 / hooks 2 / remote-exec 1 / node 1 型,零删零改名)
10
88
 
11
89
  > 本节六件,全部 additive(零删导出、零改名;minor):运行期成员表三张 · 项目登记扩形 + PATCH 形 · 命令行环境错一臂 · 一次性迁移 SS-M7 走到 `remote-exec.json` ·
@@ -1 +1 @@
1
- 11.3.0
1
+ 12.0.1
@@ -80,7 +80,8 @@
80
80
  "domain": "models",
81
81
  "identity": {
82
82
  "id": "withheld-id",
83
- "nameWithheld": true
83
+ "nameWithheld": true,
84
+ "token": "withheld:5c1dac79e895"
84
85
  },
85
86
  "kind": "entry-dropped",
86
87
  "path": [
@@ -107,7 +108,8 @@
107
108
  "domain": "models",
108
109
  "identity": {
109
110
  "id": "short-bearer-id",
110
- "nameWithheld": true
111
+ "nameWithheld": true,
112
+ "token": "withheld:8d196a0cf81a"
111
113
  },
112
114
  "kind": "entry-dropped",
113
115
  "path": [
@@ -120,7 +122,8 @@
120
122
  "domain": "models",
121
123
  "identity": {
122
124
  "id": "pem-id",
123
- "nameWithheld": true
125
+ "nameWithheld": true,
126
+ "token": "withheld:ffe22d022741"
124
127
  },
125
128
  "kind": "entry-dropped",
126
129
  "path": [
@@ -93,7 +93,8 @@
93
93
  "domain": "models",
94
94
  "identity": {
95
95
  "id": "in-charset-secret-name-id",
96
- "nameWithheld": true
96
+ "nameWithheld": true,
97
+ "token": "withheld:e4c2e8ebcfc8"
97
98
  },
98
99
  "kind": "entry-dropped",
99
100
  "path": [
@@ -106,7 +107,8 @@
106
107
  "domain": "models",
107
108
  "identity": {
108
109
  "id": "one-dot-jwt-name-id",
109
- "nameWithheld": true
110
+ "nameWithheld": true,
111
+ "token": "withheld:c30e4531b782"
110
112
  },
111
113
  "kind": "entry-dropped",
112
114
  "path": [
@@ -112,7 +112,8 @@
112
112
  "domain": "models",
113
113
  "identity": {
114
114
  "id": "cap-257-id",
115
- "nameWithheld": true
115
+ "nameWithheld": true,
116
+ "token": "withheld:389e8c0da200"
116
117
  },
117
118
  "kind": "entry-dropped",
118
119
  "path": [
@@ -174,7 +175,8 @@
174
175
  {
175
176
  "domain": "hosts",
176
177
  "identity": {
177
- "nameWithheld": true
178
+ "nameWithheld": true,
179
+ "token": "withheld:56d720b6a52f"
178
180
  },
179
181
  "kind": "entry-dropped",
180
182
  "path": [
@@ -70,7 +70,8 @@
70
70
  {
71
71
  "domain": "hosts",
72
72
  "identity": {
73
- "nameWithheld": true
73
+ "nameWithheld": true,
74
+ "token": "withheld:e2bdf1a6eb87"
74
75
  },
75
76
  "kind": "entry-dropped",
76
77
  "path": [
@@ -76,7 +76,8 @@
76
76
  "domain": "models",
77
77
  "identity": {
78
78
  "idWithheld": true,
79
- "nameWithheld": true
79
+ "nameWithheld": true,
80
+ "token": "withheld:b2fca8da7930"
80
81
  },
81
82
  "kind": "entry-dropped",
82
83
  "path": [
@@ -89,7 +90,8 @@
89
90
  "domain": "models",
90
91
  "identity": {
91
92
  "idWithheld": true,
92
- "nameWithheld": true
93
+ "nameWithheld": true,
94
+ "token": "withheld:946256d8ea6b"
93
95
  },
94
96
  "kind": "entry-dropped",
95
97
  "path": [
@@ -89,7 +89,8 @@
89
89
  "domain": "models",
90
90
  "identity": {
91
91
  "idWithheld": true,
92
- "nameWithheld": true
92
+ "nameWithheld": true,
93
+ "token": "withheld:f3b4942da5cd"
93
94
  },
94
95
  "kind": "entry-dropped",
95
96
  "path": [
@@ -102,7 +103,8 @@
102
103
  "domain": "models",
103
104
  "identity": {
104
105
  "idWithheld": true,
105
- "nameWithheld": true
106
+ "nameWithheld": true,
107
+ "token": "withheld:2469fd783cfd"
106
108
  },
107
109
  "kind": "entry-dropped",
108
110
  "path": [
@@ -19,10 +19,16 @@
19
19
  * (不在链接位置建普通文件,也不替链接建它指向的文件);成环的链接由 `realpath` 抛 `ELOOP`。目标不存在 ⇒ 新建(没有链接可言;经符号链接的目录写新文件时,文件落在真目录里)。
20
20
  * 「链接 ⇒ 真文件、悬空 ⇒ 响亮抛」这一只判法是 {@link resolveSymlinkTarget}:目录迁移的读口(`./config-dir-io`)读目录 / 读文件前也问它,
21
21
  * 悬空链接在读阶段与写阶段是**同一句**({@link danglingSymlinkSentence})。
22
+ *
23
+ * ## 写失败(12.0.0;句子归文件系统失败句的唯一铸点)
24
+ * 两步各一句({@link FsFailure},句子在 `./fs-failure`):临时文件建不出 ⇒ 「cannot write into directory <dir> (<errno>): …」(点名目录 —— 目录不可写时
25
+ * 文件本身没错);`rename` 失败 ⇒ 「cannot replace <file> (<errno>): …」(点名真文件)。认得的 errno 给对应出路,别的给 Node 自己的句子;`code` / `cause` 留着。
26
+ * 可写目录里的只读文件照常写得进(`rename` 换的是目录项)。
22
27
  */
23
28
  import { randomBytes } from "node:crypto";
24
29
  import { chmodSync, lstatSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
25
30
  import path from "node:path";
31
+ import { FsFailure } from "./fs-failure.js";
26
32
  /** 按文档类的模式表(闭集)。 */
27
33
  export const ATOMIC_FILE_CLASSES = {
28
34
  "config-document": { newMode: 0o644, existing: "keep" },
@@ -79,11 +85,14 @@ export function writeFileAtomic(target, text, kind) {
79
85
  throw new Error(`writeFileAtomic: unhandled existing-file rule ${String(unhandled)}`);
80
86
  }
81
87
  }
82
- const tmp = path.join(path.dirname(real), `.${path.basename(real)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
88
+ const dir = path.dirname(real);
89
+ const tmp = path.join(dir, `.${path.basename(real)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
90
+ let site = { landing: "dir-create-temp", path: tmp };
83
91
  try {
84
92
  writeFileSync(tmp, text, { encoding: "utf8", mode: kept ?? spec.newMode });
85
93
  if (kept !== undefined)
86
94
  chmodSync(tmp, kept); // a kept mode is restored exactly (the creation mode above went through the umask)
95
+ site = { landing: "rename", path: real };
87
96
  renameSync(tmp, real);
88
97
  }
89
98
  catch (e) {
@@ -93,6 +102,6 @@ export function writeFileAtomic(target, text, kind) {
93
102
  catch {
94
103
  /* 临时文件没建成:没什么可清 */
95
104
  }
96
- throw e;
105
+ throw new FsFailure(site, e); // the sentence names the step that failed and its way out (`./fs-failure`, the one mint)
97
106
  }
98
107
  }
package/dist/cli.d.ts CHANGED
@@ -15,8 +15,9 @@
15
15
  * 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
16
16
  *
17
17
  * 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
18
- * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与错误(errno 码在 Node 的错误句里),不打栈 ——
19
- * rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
18
+ * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与 errno,不打栈;句子按落点分句、
19
+ * 一律出自文件系统失败句的唯一铸点 `./fs-failure` —— 例:目录建不出文件 ⇒ 点名目录、目标换不掉 ⇒ 点名文件,句尾是对应出路)——
20
+ * rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或覆盖面内某份文档(域文档或 `remote-exec.json`)是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` / `loadRemoteExec` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
20
21
  * (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
21
22
  * 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
22
23
  *
package/dist/cli.js CHANGED
@@ -15,8 +15,9 @@
15
15
  * 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
16
16
  *
17
17
  * 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
18
- * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与错误(errno 码在 Node 的错误句里),不打栈 ——
19
- * rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
18
+ * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与 errno,不打栈;句子按落点分句、
19
+ * 一律出自文件系统失败句的唯一铸点 `./fs-failure` —— 例:目录建不出文件 ⇒ 点名目录、目标换不掉 ⇒ 点名文件,句尾是对应出路)——
20
+ * rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或覆盖面内某份文档(域文档或 `remote-exec.json`)是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` / `loadRemoteExec` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
20
21
  * (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
21
22
  * 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
22
23
  *
@@ -30,6 +31,7 @@
30
31
  import { reportReceipt, joinDirPath, migrateConfigDirectory } from "./config-dir-migration.js";
31
32
  import { MIGRATE_COMMAND_TEMPLATE, migrateCommandFor } from "./retired-form.js";
32
33
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
34
+ import { fsFailureMessage } from "./fs-failure.js";
33
35
  import { DOMAINS, RETIRED_DOMAINS } from "./types.js";
34
36
  /** 命令行的覆盖面:全部配置域、retired 域(便携与否不论 —— 离线的 `workers.json` 一样迁)与 `remote-exec.json`。 */
35
37
  export const COVERED_DOCUMENTS = [...DOMAINS, ...Object.keys(RETIRED_DOMAINS), REMOTE_EXEC_DOCUMENT];
@@ -76,9 +78,9 @@ function reportOf(outcome, dir, applied, refused) {
76
78
  class EnvironmentFailure extends Error {
77
79
  }
78
80
  const oneLine = (text) => text.replace(/\s*\n\s*/g, " ");
79
- const messageOf = (err) => oneLine(err instanceof Error ? err.message : String(err));
80
- /** 把注入的写口包一层:写盘 / 删文件抛 ⇒ {@link EnvironmentFailure},句子点名**要写的那份文件**(原子写器的错误句里是临时文件的路径)与原错误句。
81
- * 读口不包:读目录 / 读文件错由 `migrateConfigDirectory` 自己收成 `unreadable`(同为 rc 2)。 */
81
+ /** 把注入的写口包一层:写盘 / 删文件抛 ⇒ {@link EnvironmentFailure},句子由铸点出(`fsFailureMessage`):本包的原子写器抛的是按失败那一步分好的句子
82
+ * (目录建不出文件 ⇒ 点名目录,目标换不掉 ⇒ 点名文件,句尾是对应出路)—— 原样用;别的写口(注入的替身)抛的错步骤看不见 ⇒ 点名**要写的那份文件**与原错误句。
83
+ * 读口不包:读目录 / 读文件错由 `migrateConfigDirectory` 自己收成 `unreadable`(同为 rc 2,句子同出铸点)。 */
82
84
  function withEnvironmentArm(io) {
83
85
  return {
84
86
  readDir: (path) => io.readDir(path),
@@ -88,7 +90,7 @@ function withEnvironmentArm(io) {
88
90
  await io.writeFileAtomic(path, text);
89
91
  }
90
92
  catch (err) {
91
- throw new EnvironmentFailure(`cannot write ${path}: ${messageOf(err)}`, { cause: err });
93
+ throw new EnvironmentFailure(fsFailureMessage({ landing: "write", path }, err), { cause: err });
92
94
  }
93
95
  },
94
96
  removeFile: async (path) => {
@@ -96,7 +98,7 @@ function withEnvironmentArm(io) {
96
98
  await io.removeFile(path);
97
99
  }
98
100
  catch (err) {
99
- throw new EnvironmentFailure(`cannot remove ${path}: ${messageOf(err)}`, { cause: err });
101
+ throw new EnvironmentFailure(fsFailureMessage({ landing: "remove", path }, err), { cause: err });
100
102
  }
101
103
  },
102
104
  };
@@ -125,7 +127,7 @@ export async function runSettingsSchemaCli(argv, io) {
125
127
  if (!(err instanceof EnvironmentFailure))
126
128
  throw err;
127
129
  // 写回阶段断在这里:之前写完的文档已在新形上,再跑一次幂等收尾(迁移只认形)。stdout 不出结论行 —— 没有 `outcome=applied`。
128
- io.stderr(`settings-schema: ${err.message} — files written before it keep their migrated form; fix the cause and re-run\n`);
130
+ io.stderr(`settings-schema: ${err.message} — files written before it keep their migrated form; re-run once that is fixed\n`);
129
131
  return 2;
130
132
  }
131
133
  switch (m.kind) {
@@ -1,20 +1,19 @@
1
- import { RemoteExecSpec } from "./remote-exec.js";
1
+ import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
2
2
  import { type DomainName, type RetiredDomainName } from "./types.js";
3
3
  /** 一份 `config.d` 文档写盘的**文本形** —— 两格缩进的 JSON 加一个换行。写域文件(`writeDomainFile`)、写 `remote-exec.json`(`writeRemoteExec`)与目录迁移写回
4
4
  * 都用这一只(此前三处各写一遍同一行),于是迁移写回的文件与编辑器写出的同一份文件逐字节同形。 */
5
5
  export declare function configDDocumentText(value: unknown): string;
6
- /** `config.d/remote-exec.json` 在场时的**文本** → 它的 JSON 值(纯判,零 I/O)—— `loadRemoteExec` 与目录迁移读同一只、同一句(从 `loadRemoteExec` 里搬出)。
7
- * 空 / 纯空白(半写、截断 —— 不是「用默认」:默认车道要的是文件**缺席**)、坏 JSON ⇒ 抛,句子点名 `filePath`、不回显原文。值的形由
8
- * {@link parseRemoteExecDocument} 判(不是对象也归它拒)。 */
9
- export declare function judgeRemoteExecText(filePath: string, text: string): unknown;
10
6
  /** 判一份 remote-exec 文档的值(`RemoteExecSpec`),错误**脱敏**(zod 会回显出错的值 —— 粘错位置的密钥不许随抛出的错走);反射访问抛(恶意 getter /
11
7
  * Proxy)⇒ 定句。`loadRemoteExec` / `writeRemoteExec` 与目录迁移的整文档终判调同一只。 */
12
8
  export declare function parseRemoteExecDocument(raw: unknown): RemoteExecSpec;
13
9
  /** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是配置域也不是 retired 域 ⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
14
10
  export declare function configDPath(root: string, name: string): string;
11
+ /** `config.d` 里能放的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`,不是配置域)。 */
12
+ export type ConfigDDocumentName = DomainName | RetiredDomainName | typeof REMOTE_EXEC_DOCUMENT;
15
13
  /** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、
16
- * 不是对象 ⇒ 抛。读盘的入口(`readConfigDFile`)与自己读文本的命令行(`migrate`)都过它,四种坏形一句话。 */
17
- export declare function judgeConfigDText(name: DomainName | RetiredDomainName, text: string): Record<string, unknown>;
14
+ * 不是对象 ⇒ 抛,每种一句(点名 `file` —— 调用方读的那份文件的真实路径 —— 与形,句尾 {@link documentWayOut})。读盘的入口(`readConfigDFile`、`loadRemoteExec`)
15
+ * 与自己读文本的命令行(`migrate`)都过它 —— 一种坏形一句话,不论是哪份文档。 */
16
+ export declare function judgeConfigDText(name: ConfigDDocumentName, text: string, file: string): Record<string, unknown>;
18
17
  /** 读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ {@link judgeConfigDText}。 */
19
18
  export declare function readConfigDFile(root: string, name: DomainName | RetiredDomainName): Promise<Record<string, unknown> | undefined>;
20
19
  /**
@@ -1,36 +1,24 @@
1
1
  /**
2
- * 包内私有(不进主桶、不进 exports 表):**一只** `config.d/<名>.json` 文件读法 —— `FileConfigStore`、`readBundle` 与命令行 `migrate` 读同一个目录,
3
- * 同一种文件形同一个答案、同一句话。分两只:纯判 `judgeConfigDText`(文本 → 值 | 抛,零 I/O;命令行自己读文本,只调它)与读盘的
4
- * `readConfigDFile`(调它);路径与名字的判只在 `configDPath`。
2
+ * 包内私有(不进主桶、不进 exports 表):**一只** `config.d/<名>.json` 文件读法 —— `FileConfigStore`、`readBundle`、`loadRemoteExec` 与命令行 `migrate` 读同一个目录,
3
+ * 同一种文件形同一个答案、同一句话(12.0.0 起 `remote-exec.json` 也是这一只;此前它有自己的一只文本判,只拒空与坏 JSON,非对象在命令行上被当成「无事可做」)。
4
+ * 分两只:纯判 `judgeConfigDText`(文本 → 值 | 抛,零 I/O;命令行与 `loadRemoteExec` 自己读文本,只调它)与读盘的 `readConfigDFile`(调它);路径与名字的判只在 `configDPath`。
5
5
  *
6
6
  * 律:**只有不存在(ENOENT)= 缺席**(`undefined`);在场的文件必须是一个 JSON 对象。空 / 纯空白(半写、截断)、坏 JSON、解析出来不是对象
7
7
  * (`null` / 数组 / 数 / 串 / 布尔)一律是**坏文件** ⇒ 抛(fail-closed:把一个被截断的文件读成缺席,就是静默丢掉那一个域 —— 例:截断的
8
- * governance.json 丢掉它的护栏)。拒句只点名文件与形,从不回显文件原文。
8
+ * governance.json 丢掉它的护栏;截断的 remote-exec.json 把隔离车道降成宿主车道)。三种坏形每种一句,点名**调用方寻址这份文档用的路径**与形(读的人给路径,
9
+ * 判官不拼 `config.d/` 字面 —— 多个目录各有一份时,句子认得出是哪个 scope 的哪一份;文档是符号链接时点名链接这一端:句尾「delete it」删的是这个 scope 里的这一项,不是被几个 scope 共用的目标)、
10
+ * 句尾出路按文档类({@link documentWayOut}),从不回显文件原文。
9
11
  */
10
12
  import { readFile } from "node:fs/promises";
11
13
  import path from "node:path";
12
14
  import { refuseRetiredDomain, sanitizeZodError } from "./config-fns.js";
13
- import { RemoteExecSpec } from "./remote-exec.js";
15
+ import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
14
16
  import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
15
17
  /** 一份 `config.d` 文档写盘的**文本形** —— 两格缩进的 JSON 加一个换行。写域文件(`writeDomainFile`)、写 `remote-exec.json`(`writeRemoteExec`)与目录迁移写回
16
18
  * 都用这一只(此前三处各写一遍同一行),于是迁移写回的文件与编辑器写出的同一份文件逐字节同形。 */
17
19
  export function configDDocumentText(value) {
18
20
  return `${JSON.stringify(value, null, 2)}\n`;
19
21
  }
20
- /** `config.d/remote-exec.json` 在场时的**文本** → 它的 JSON 值(纯判,零 I/O)—— `loadRemoteExec` 与目录迁移读同一只、同一句(从 `loadRemoteExec` 里搬出)。
21
- * 空 / 纯空白(半写、截断 —— 不是「用默认」:默认车道要的是文件**缺席**)、坏 JSON ⇒ 抛,句子点名 `filePath`、不回显原文。值的形由
22
- * {@link parseRemoteExecDocument} 判(不是对象也归它拒)。 */
23
- export function judgeRemoteExecText(filePath, text) {
24
- const trimmed = text.trim();
25
- if (!trimmed)
26
- throw new Error(`${filePath} is empty/corrupt — delete it for the host default, or write a valid remoteExec spec`);
27
- try {
28
- return JSON.parse(trimmed); // sanitized: don't let a SyntaxError echo the raw (possibly secret-bearing) file text
29
- }
30
- catch {
31
- throw new Error(`${filePath} — invalid JSON (delete it for the host default, or write a valid remoteExec spec)`);
32
- }
33
- }
34
22
  /** 判一份 remote-exec 文档的值(`RemoteExecSpec`),错误**脱敏**(zod 会回显出错的值 —— 粘错位置的密钥不许随抛出的错走);反射访问抛(恶意 getter /
35
23
  * Proxy)⇒ 定句。`loadRemoteExec` / `writeRemoteExec` 与目录迁移的整文档终判调同一只。 */
36
24
  export function parseRemoteExecDocument(raw) {
@@ -51,37 +39,43 @@ export function configDPath(root, name) {
51
39
  throw new Error(`"${name}" is not a config domain — refusing to build a config.d path from it (no traversal)`);
52
40
  return path.join(root, "config.d", `${name}.json`);
53
41
  }
42
+ /** 坏文件句的出路(按文档类):删掉它 = 回到缺席时的缺省(域 ⇒ 域缺省;`remote-exec` ⇒ 宿主车道缺省),或写成那一类的合法文档。 */
43
+ function documentWayOut(name) {
44
+ return name === REMOTE_EXEC_DOCUMENT ? "delete it for the host default, or write a valid remoteExec spec" : "delete it for the domain default, or write a valid object";
45
+ }
54
46
  /** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、
55
- * 不是对象 ⇒ 抛。读盘的入口(`readConfigDFile`)与自己读文本的命令行(`migrate`)都过它,四种坏形一句话。 */
56
- export function judgeConfigDText(name, text) {
47
+ * 不是对象 ⇒ 抛,每种一句(点名 `file` —— 调用方读的那份文件的真实路径 —— 与形,句尾 {@link documentWayOut})。读盘的入口(`readConfigDFile`、`loadRemoteExec`)
48
+ * 与自己读文本的命令行(`migrate`)都过它 —— 一种坏形一句话,不论是哪份文档。 */
49
+ export function judgeConfigDText(name, text, file) {
57
50
  const trimmed = text.trim();
58
51
  if (!trimmed)
59
- throw new Error(`config.d/${name}.json is empty/whitespace — delete it for the domain default, or write a valid object`);
52
+ throw new Error(`${file} is empty/whitespace — ${documentWayOut(name)}`);
60
53
  let parsed;
61
54
  try {
62
55
  parsed = JSON.parse(trimmed);
63
56
  }
64
57
  catch {
65
- throw new Error(`invalid JSON in config.d/${name}.json`); // sanitized: never echo the raw file text into the error
58
+ throw new Error(`invalid JSON in ${file} — ${documentWayOut(name)}`); // sanitized: never echo the raw file text into the error
66
59
  }
67
60
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
68
61
  const kind = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
69
- throw new Error(`config.d/${name}.json must be a JSON object (got ${kind}) — delete the file for the domain default, or write a valid object`);
62
+ throw new Error(`${file} must be a JSON object (got ${kind}) — ${documentWayOut(name)}`);
70
63
  }
71
64
  return parsed;
72
65
  }
73
66
  /** 读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ {@link judgeConfigDText}。 */
74
67
  export async function readConfigDFile(root, name) {
68
+ const file = configDPath(root, name);
75
69
  let text;
76
70
  try {
77
- text = await readFile(configDPath(root, name), "utf8");
71
+ text = await readFile(file, "utf8");
78
72
  }
79
73
  catch (e) {
80
74
  if (e.code === "ENOENT")
81
75
  return undefined;
82
76
  throw e;
83
77
  }
84
- return judgeConfigDText(name, text);
78
+ return judgeConfigDText(name, text, file);
85
79
  }
86
80
  /**
87
81
  * `config.d/` 里每个 **retired 域**的文件过一次墓碑(`refuseRetiredDomain`,与 `buildEffective` / `parseDomain` 同一只判官、同一句):带事实 ⇒
@@ -1,6 +1,5 @@
1
+ import { type ConfigDDocumentName } from "./config-d-file.js";
1
2
  import { type MigrationReceipt } from "./one-time-migrations.js";
2
- import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
3
- import { type DomainName, type RetiredDomainName } from "./types.js";
4
3
  /** 注入的 I/O(本模块不碰 `node:fs`)。`writeFileAtomic` 必须原子(写临时文件再改名):半写的域文档比旧文档更糟。 */
5
4
  export interface ConfigDirectoryIo {
6
5
  readDir(path: string): Promise<string[]>;
@@ -50,8 +49,8 @@ export interface MigrateConfigDirectoryOptions {
50
49
  /** 只判、不写:有改动且整域判得过 ⇒ `pending`。默认 `false`。 */
51
50
  readonly check?: boolean;
52
51
  }
53
- /** 目录迁移能读写的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`)。 */
54
- export type ConfigDirectoryDocument = DomainName | RetiredDomainName | typeof REMOTE_EXEC_DOCUMENT;
52
+ /** 目录迁移能读写的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`)—— 就是 `config.d` 里能放的文档名。 */
53
+ export type ConfigDirectoryDocument = ConfigDDocumentName;
55
54
  /** 目录加文件名(目录尾部的分隔符折掉)。包内私有(不进 `./node`)。 */
56
55
  export declare const joinDirPath: (dir: string, name: string) => string;
57
56
  type RefusedReceipt = Extract<MigrationReceipt, {
@@ -23,16 +23,13 @@
23
23
  */
24
24
  import { z } from "zod";
25
25
  import { formatConfigPath, parseDomain } from "./config-fns.js";
26
- import { configDDocumentText, judgeConfigDText, judgeRemoteExecText, parseRemoteExecDocument } from "./config-d-file.js";
26
+ import { configDDocumentText, judgeConfigDText, parseRemoteExecDocument } from "./config-d-file.js";
27
+ import { fsFailureMessage } from "./fs-failure.js";
27
28
  import { migrateStoredDocuments } from "./one-time-migrations.js";
28
29
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
29
30
  import { RUNTIME_LIFT_TARGETS } from "./runtime-lift-targets.js";
30
31
  import { isDomainName, isRetiredDomainName } from "./types.js";
31
32
  const isConfigDirectoryDocument = (name) => isDomainName(name) || isRetiredDomainName(name) || name === REMOTE_EXEC_DOCUMENT;
32
- /** 一份文档在场时的文本 → 值:域 / retired 域过 `judgeConfigDText`(与 `FileConfigStore` 同一只),`remote-exec` 过 `judgeRemoteExecText`(与 `loadRemoteExec` 同一只)。 */
33
- function judgeDocumentText(dir, name, text) {
34
- return name === REMOTE_EXEC_DOCUMENT ? judgeRemoteExecText(joinDirPath(dir, `${name}.json`), text) : judgeConfigDText(name, text);
35
- }
36
33
  /** 改过的文档的整文档终判:域过读侧那一只 `parseDomain`,`remote-exec` 过 `loadRemoteExec` 那一只 `parseRemoteExecDocument`;retired 域不终判(它只会被删)。 */
37
34
  function judgeWholeDocument(name, doc) {
38
35
  if (isDomainName(name))
@@ -113,7 +110,7 @@ export async function migrateConfigDirectory(dir, opts) {
113
110
  names = await io.readDir(dir);
114
111
  }
115
112
  catch (err) {
116
- return { kind: "unreadable", reason: `cannot read directory ${dir}: ${messageOf(err)}` };
113
+ return { kind: "unreadable", reason: fsFailureMessage({ landing: "read", path: dir, of: "directory" }, err) };
117
114
  }
118
115
  const docs = {};
119
116
  for (const name of [...names].sort()) {
@@ -123,14 +120,15 @@ export async function migrateConfigDirectory(dir, opts) {
123
120
  if (!covered.has(base) || !isConfigDirectoryDocument(base))
124
121
  continue;
125
122
  let text;
123
+ const file = joinDirPath(dir, name);
126
124
  try {
127
- text = await io.readFile(joinDirPath(dir, name));
125
+ text = await io.readFile(file);
128
126
  }
129
127
  catch (err) {
130
- return { kind: "unreadable", reason: `cannot read ${name}: ${messageOf(err)}` };
128
+ return { kind: "unreadable", reason: fsFailureMessage({ landing: "read", path: file, of: "file" }, err) };
131
129
  }
132
130
  try {
133
- docs[base] = judgeDocumentText(dir, base, text); // the one judge per document kind: a domain file's is FileConfigStore's, remote-exec.json's is loadRemoteExec's
131
+ docs[base] = judgeConfigDText(base, text, file); // the one config.d text judge (FileConfigStore / loadRemoteExec read with it): empty / bad JSON / not an object ⇒ unreadable
134
132
  }
135
133
  catch (err) {
136
134
  return { kind: "unreadable", reason: messageOf(err) };
@@ -182,8 +182,10 @@ export declare function outgoingPath(p: ConfigPath): ConfigPath;
182
182
  * 所以是**不带**而不是打码)。三键都读不出 ⇒ 整个身份缺席(不铸空对象)。
183
183
  *
184
184
  * **扣名 ≠ 读不出**(11.0.0,N-4 / S-759):`name` / `id` 是字符串但不能原样出门 ⇒ 身份带 `nameWithheld: true` / `idWithheld: true`;
185
- * 不是字符串 ⇒ 什么都不带。两种处置对消费方不同:「这一行在场、有名、名被扣下」的启用行仍是一条在场的条目(server 据此铸无名占位,
186
- * 不走「零启用」捷径),「没有名」的行不是。标记只说「有一个名、没给出」,不说为什么(秘密形 / 超长 / 字符集外是同一个记号)。
185
+ * 不是字符串 ⇒ 什么都不带。标记只说「有一个名、没给出」,不说为什么(秘密形 / 超长 / 字符集外是同一个记号)。扣下的**名**另带一个可比记号
186
+ * `token`(12.0.0,{@link withheldNameToken}):两条扣名行据此可分、消费方拿请求里的名字调同一只就能比对,原名一个字节都不出门。
187
+ * 身份只回答「丢的是谁」,不回答「这条坏行算不算在场」:一条读不出名字的被丢行照样是文档里的一行,在场与否是消费方按原文判的事
188
+ * (例:server 把没写 `enabled: false` 的被丢行一律算在场)。
187
189
  *
188
190
  * 为什么要带:条目被丢之后它的名字从目录里消失,只给坐标的话,消费方要知道「丢的是谁」只能拿坐标回读
189
191
  * 原文档 —— 而它手里那份原文档未必是这一次读到的那份。铸告警的这一层判的就是那条原文,在这里取一次、
@@ -194,6 +196,9 @@ export interface DroppedEntryIdentity {
194
196
  enabled?: boolean;
195
197
  /** `name` was a string that {@link isDisclosableIdentity} would not let out (11.0.0). Absent with a `name` present or with no string name. */
196
198
  nameWithheld?: true;
199
+ /** The withheld name's comparable token ({@link withheldNameToken}; 12.0.0) — present exactly when `nameWithheld` is. Stable (the same name
200
+ * always gives the same token), irreversible, never the name or any part of it. */
201
+ token?: string;
197
202
  /** `id` was a string that {@link isDisclosableIdentity} would not let out (11.0.0). */
198
203
  idWithheld?: true;
199
204
  }
@@ -251,6 +256,13 @@ export declare function isDisclosableIdentity(v: string): boolean;
251
256
  * ② {@link isDisclosableIdentity}(不是白名单闭形里的标识符,或是秘密形 ⇒ 不出门)。
252
257
  * 非字符串 ⇒ false。住在本文件而不是 `./fleet`:`fleet.ts` 被 `types.ts` 引,它再引本文件会成环。 */
253
258
  export declare function isReportableRefusalName(name: string): boolean;
259
+ /** 扣下的名的**可比记号**(12.0.0)—— `withheld:` + SHA-256(域分隔前缀 + 原名的 JSON 串形)的前 12 个十六进制位。**唯一**铸法:被丢条目的身份
260
+ * ({@link DroppedEntryIdentity}`.token`)由它铸,消费方要问「请求点名的这个名是不是那条扣名行」就对请求里的名调它、比 token —— 不另写一份摘要。
261
+ * 稳定(同名恒同记号)、不可逆(48 位摘要,秘密形名字本身是高熵串);域分隔前缀让它与别处对同一串算的 SHA-256 指纹对不上。它只区分名字,
262
+ * 不是 MAC、不防字典猜测:低熵的名(`Basic Chat`)能被猜回来 —— 那类名被扣下是因为字符集外,本就不是秘密。
263
+ * 摘要输入取 `JSON.stringify(name)`:UTF-8 编码会把孤立代理项换成 U+FFFD(`"\ud800"` / `"\ud801"` / `"\ufffd"` 三个不同的名同一串字节),
264
+ * JSON 串形逐个 UTF-16 码元保留(孤立代理项写成转义)—— 不同的名恒是不同的输入。 */
265
+ export declare function withheldNameToken(name: string): string;
254
266
  /** 抢救造成的**下标位移表**:数组字段名 → 该字段压缩后每个位置的**原**下标(`kept[j]` = 压缩后第 j 条
255
267
  * 在原文档里的下标)。纯数据,无行为。
256
268
  *
@@ -18,6 +18,7 @@
18
18
  import { z } from "zod";
19
19
  import { MODEL_ENTRY_REFUSAL_CODES, ModelRefusalsWire } from "./fleet.js";
20
20
  import { carriesUserinfo, secretScanForms } from "./secret-scan.js"; // package-private normalization (both scanners read it) + the userinfo judgement (not in the barrel)
21
+ import { sha256Hex } from "./sha256.js";
21
22
  import { unwrapSchema } from "./zod-unwrap.js"; // package-private: the ONE reading of zod's wrapper layers (salvage + the one-time migrations share it)
22
23
  import { DOMAIN_SCHEMAS, InfraCostRates, LimitsConfig, PRIVATE_KEY_PEM_HEADER, RETIRED_DOMAINS, SkillsManifestSchema, containsSecretToken, isDomainName, isRetiredDomainName, renderQuotedSentence, retiredDomainSentence } from "./types.js";
23
24
  /** Redact secret-shaped tokens from a validation MESSAGE — a zod error echoes the offending value (e.g.
@@ -327,6 +328,17 @@ export function isReportableRefusalName(name) {
327
328
  const probe = ModelRefusalsWire.safeParse({ entries: { [name]: MODEL_ENTRY_REFUSAL_CODES[0] } });
328
329
  return probe.success && Object.hasOwn(probe.data.entries, name);
329
330
  }
331
+ /** {@link withheldNameToken} 的域分隔前缀(摘要输入 = 前缀 + 原名的 JSON 串形)。 */
332
+ const WITHHELD_NAME_TOKEN_DOMAIN = "sema-settings-schema/withheld-name\u0000";
333
+ /** 扣下的名的**可比记号**(12.0.0)—— `withheld:` + SHA-256(域分隔前缀 + 原名的 JSON 串形)的前 12 个十六进制位。**唯一**铸法:被丢条目的身份
334
+ * ({@link DroppedEntryIdentity}`.token`)由它铸,消费方要问「请求点名的这个名是不是那条扣名行」就对请求里的名调它、比 token —— 不另写一份摘要。
335
+ * 稳定(同名恒同记号)、不可逆(48 位摘要,秘密形名字本身是高熵串);域分隔前缀让它与别处对同一串算的 SHA-256 指纹对不上。它只区分名字,
336
+ * 不是 MAC、不防字典猜测:低熵的名(`Basic Chat`)能被猜回来 —— 那类名被扣下是因为字符集外,本就不是秘密。
337
+ * 摘要输入取 `JSON.stringify(name)`:UTF-8 编码会把孤立代理项换成 U+FFFD(`"\ud800"` / `"\ud801"` / `"\ufffd"` 三个不同的名同一串字节),
338
+ * JSON 串形逐个 UTF-16 码元保留(孤立代理项写成转义)—— 不同的名恒是不同的输入。 */
339
+ export function withheldNameToken(name) {
340
+ return `withheld:${sha256Hex(`${WITHHELD_NAME_TOKEN_DOMAIN}${JSON.stringify(name)}`).slice(0, 12)}`;
341
+ }
330
342
  /** 一条被丢条目原文的身份({@link DroppedEntryIdentity} 的**唯一**读法)。只看纯 JSON 对象的**自有数据
331
343
  * 属性**;任何反射/陷阱抛错 ⇒ 当作读不出(身份是诊断面的附件,绝不许把抢救拖下水)。 */
332
344
  function readEntryIdentity(entry) {
@@ -341,7 +353,7 @@ function readEntryIdentity(entry) {
341
353
  const id = own("id");
342
354
  const enabled = own("enabled");
343
355
  const identity = {
344
- ...(typeof name === "string" ? (isDisclosableIdentity(name) ? { name } : { nameWithheld: true }) : {}),
356
+ ...(typeof name === "string" ? (isDisclosableIdentity(name) ? { name } : { nameWithheld: true, token: withheldNameToken(name) }) : {}),
345
357
  ...(typeof id === "string" ? (isDisclosableIdentity(id) ? { id } : { idWithheld: true }) : {}),
346
358
  ...(typeof enabled === "boolean" ? { enabled } : {}),
347
359
  };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表;零 `node:` 依赖):**文件系统失败句的唯一铸点** —— `(errno, 落点, 真实路径)` → 一句。
3
+ * 本包凡是把文件系统错误说成人话的地方(原子写器、命令行的写 / 删口、目录迁移的读口)都调 {@link fsFailureSentence},没有第二处手写的 `cannot …` 句子
4
+ * (`test/` 下的 `fs-failure-single-mint-gate` 门扫 `src/` 里的字符串字面量,铸点之外出现即红)。
5
+ *
6
+ * 落点({@link FsFailureSite}.`landing`,闭集)按失败发生在哪一步分句 —— 同一个 errno 在不同落点指向不同的东西:
7
+ * · `dir-create-temp` —— 原子写器在目标所在目录里建临时文件。目录不让建 ⇒ 点名**目录**(文件本身没错);
8
+ * · `rename` —— 原子写器 `rename` 盖过目标。目录收了新文件、却换不掉这一份 ⇒ 点名**真文件**;
9
+ * · `remove` —— 删一个文件。删的是目录项:目录不让删 ⇒ 点名文件所在的**目录**(与建临时文件同一个因);
10
+ * · `read` —— 读一个目录或文件;
11
+ * · `write` —— 写口整体失败、步骤看不见(注入的写口抛的错,不是本包原子写器抛的):只点名要写的文件。
12
+ * 认得的 errno 给那一步对应的出路,别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。两条与落点无关的律:
13
+ * · errno 只打一遍 —— 句首括号里一次,Node 句子自带的 `<errno>: ` 前缀去掉;
14
+ * · `ENAMETOOLONG`(路径某一节或整条超限)既不是目录的错也不是文件的错 ⇒ 一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`,不点名目录、不给权限类出路。
15
+ * 目录落点(`dir-create-temp` / `remove`)的 `EPERM` / `EROFS` 出路句尾加「目录可能是只读 / 不可变(chflags / chattr)」—— 改权限位修不好的那两种。
16
+ *
17
+ * 原子写器抛 {@link FsFailure}(句子在 `message`,`code` 照抄底层 errno,原错误在 `cause`);它在写器内部已经按更细的落点铸过句子,
18
+ * 命令行 / 迁移用 {@link fsFailureMessage} 取句 —— 已经是 {@link FsFailure} 的原样用,否则按它所在的口铸。
19
+ */
20
+ /** 一次失败发生的地方(闭集):落点加那一步交给操作系统的真实路径(`dir-create-temp` 是要建的临时文件,点名的目录是它的父目录;`rename` 是被盖过的真文件;
21
+ * `remove` / `write` 是那份文件;`read` 是读的目录或文件)。 */
22
+ export type FsFailureSite = {
23
+ readonly landing: "dir-create-temp";
24
+ readonly path: string;
25
+ } | {
26
+ readonly landing: "rename";
27
+ readonly path: string;
28
+ } | {
29
+ readonly landing: "remove";
30
+ readonly path: string;
31
+ } | {
32
+ readonly landing: "read";
33
+ readonly path: string;
34
+ readonly of: "directory" | "file";
35
+ } | {
36
+ readonly landing: "write";
37
+ readonly path: string;
38
+ };
39
+ /** 一次文件系统失败的句子(纯函数)。 */
40
+ export declare function fsFailureSentence(site: FsFailureSite, cause: unknown): string;
41
+ /** 原子写器抛的错:句子见 {@link fsFailureSentence};`code` 照抄底层 errno(按 `code` 分支的调用方不受影响),原错误在 `cause`。 */
42
+ export declare class FsFailure extends Error {
43
+ readonly landing: FsFailureSite["landing"];
44
+ readonly code: string | undefined;
45
+ constructor(site: FsFailureSite, cause: unknown);
46
+ }
47
+ /** 取一次失败的句子:{@link FsFailure}(已在更细的落点铸过)原样用,别的错按调用方所在的口 `site` 铸。 */
48
+ export declare function fsFailureMessage(site: FsFailureSite, err: unknown): string;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表;零 `node:` 依赖):**文件系统失败句的唯一铸点** —— `(errno, 落点, 真实路径)` → 一句。
3
+ * 本包凡是把文件系统错误说成人话的地方(原子写器、命令行的写 / 删口、目录迁移的读口)都调 {@link fsFailureSentence},没有第二处手写的 `cannot …` 句子
4
+ * (`test/` 下的 `fs-failure-single-mint-gate` 门扫 `src/` 里的字符串字面量,铸点之外出现即红)。
5
+ *
6
+ * 落点({@link FsFailureSite}.`landing`,闭集)按失败发生在哪一步分句 —— 同一个 errno 在不同落点指向不同的东西:
7
+ * · `dir-create-temp` —— 原子写器在目标所在目录里建临时文件。目录不让建 ⇒ 点名**目录**(文件本身没错);
8
+ * · `rename` —— 原子写器 `rename` 盖过目标。目录收了新文件、却换不掉这一份 ⇒ 点名**真文件**;
9
+ * · `remove` —— 删一个文件。删的是目录项:目录不让删 ⇒ 点名文件所在的**目录**(与建临时文件同一个因);
10
+ * · `read` —— 读一个目录或文件;
11
+ * · `write` —— 写口整体失败、步骤看不见(注入的写口抛的错,不是本包原子写器抛的):只点名要写的文件。
12
+ * 认得的 errno 给那一步对应的出路,别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。两条与落点无关的律:
13
+ * · errno 只打一遍 —— 句首括号里一次,Node 句子自带的 `<errno>: ` 前缀去掉;
14
+ * · `ENAMETOOLONG`(路径某一节或整条超限)既不是目录的错也不是文件的错 ⇒ 一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`,不点名目录、不给权限类出路。
15
+ * 目录落点(`dir-create-temp` / `remove`)的 `EPERM` / `EROFS` 出路句尾加「目录可能是只读 / 不可变(chflags / chattr)」—— 改权限位修不好的那两种。
16
+ *
17
+ * 原子写器抛 {@link FsFailure}(句子在 `message`,`code` 照抄底层 errno,原错误在 `cause`);它在写器内部已经按更细的落点铸过句子,
18
+ * 命令行 / 迁移用 {@link fsFailureMessage} 取句 —— 已经是 {@link FsFailure} 的原样用,否则按它所在的口铸。
19
+ */
20
+ /** `dir-create-temp` 里「目录本身不让建文件」的 errno:权限不够、只读文件系统、目录不在。出路 = 让目录可写。 */
21
+ const DIRECTORY_NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS", "ENOENT"]);
22
+ /** `remove` 里「目录不让删目录项」的 errno(同一个因:目录不可写)。目录不在 / 文件已不在(ENOENT)不是这个因,给 Node 自己的句子。 */
23
+ const DIRECTORY_ENTRY_NOT_REMOVABLE = new Set(["EACCES", "EPERM", "EROFS"]);
24
+ /** `rename` 里「目录收新文件、却换不掉这一份」的 errno:不可变标志、粘滞目录里别人的文件。出路 = 让文件可被替换。 */
25
+ const FILE_NOT_REPLACEABLE = new Set(["EACCES", "EPERM"]);
26
+ /** 目录落点(`dir-create-temp` / `remove`)的出路。`EPERM`(目录带不可变标志:macOS `chflags uchg`、Linux `chattr +i`)与 `EROFS`(只读文件系统)靠改权限位修不好,
27
+ * 出路句尾提示这一种可能;`EACCES` / `ENOENT` 是纯权限位 / 目录不在,出路就是让目录可写。 */
28
+ const directoryWayOut = (code) => (code === "EPERM" || code === "EROFS" ? "make the directory writable; directory may be read-only or immutable (chflags / chattr)" : "make the directory writable");
29
+ const errnoOf = (e) => {
30
+ const code = e?.code;
31
+ return typeof code === "string" ? code : undefined;
32
+ };
33
+ /** Node 自己的句子,折成一行;开头自带的 `<errno>: ` 去掉(errno 由句首的 `(<errno>)` 打一遍,不打两遍)。 */
34
+ const nodeSentenceOf = (e, code) => {
35
+ const line = (e instanceof Error ? e.message : String(e)).replace(/\s*\n\s*/g, " ");
36
+ return code !== undefined && line.startsWith(`${code}: `) ? line.slice(code.length + 2) : line;
37
+ };
38
+ /** 路径的父目录(字符串切法,零 `node:` 依赖;两种分隔符都认)。根下的文件 ⇒ 根;没有分隔符 ⇒ 当前目录。 */
39
+ const parentDirectory = (p) => {
40
+ const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
41
+ return i < 0 ? "." : i === 0 ? p.slice(0, 1) : p.slice(0, i);
42
+ };
43
+ /** 一次文件系统失败的句子(纯函数)。 */
44
+ export function fsFailureSentence(site, cause) {
45
+ const code = errnoOf(cause);
46
+ // 路径本身不能用(某一节超过 NAME_MAX、整条超过 PATH_MAX):与落点无关,既不是目录的错也不是文件的错 —— 一句点名被操作系统拒的路径。
47
+ if (code === "ENAMETOOLONG")
48
+ return `path too long: ${site.path} (${code})`;
49
+ const tag = code === undefined ? "" : ` (${code})`;
50
+ switch (site.landing) {
51
+ case "dir-create-temp":
52
+ return `cannot write into directory ${parentDirectory(site.path)}${tag}: ${code !== undefined && DIRECTORY_NOT_WRITABLE.has(code) ? directoryWayOut(code) : nodeSentenceOf(cause, code)}`;
53
+ case "rename":
54
+ return code !== undefined && FILE_NOT_REPLACEABLE.has(code)
55
+ ? `cannot replace ${site.path}${tag}: make the file replaceable — clear an immutable flag; in a sticky directory only the file's owner may replace it`
56
+ : `cannot replace ${site.path}${tag}: ${nodeSentenceOf(cause, code)}`;
57
+ case "remove":
58
+ return `cannot remove ${site.path} from directory ${parentDirectory(site.path)}${tag}: ${code !== undefined && DIRECTORY_ENTRY_NOT_REMOVABLE.has(code) ? directoryWayOut(code) : nodeSentenceOf(cause, code)}`;
59
+ case "read":
60
+ return `cannot read ${site.of === "directory" ? "directory " : ""}${site.path}${tag}: ${nodeSentenceOf(cause, code)}`;
61
+ case "write":
62
+ return `cannot write ${site.path}${tag}: ${nodeSentenceOf(cause, code)}`;
63
+ default: {
64
+ const unhandled = site;
65
+ throw new Error(`fsFailureSentence: unhandled landing ${JSON.stringify(unhandled)}`);
66
+ }
67
+ }
68
+ }
69
+ /** 原子写器抛的错:句子见 {@link fsFailureSentence};`code` 照抄底层 errno(按 `code` 分支的调用方不受影响),原错误在 `cause`。 */
70
+ export class FsFailure extends Error {
71
+ landing;
72
+ code;
73
+ constructor(site, cause) {
74
+ super(fsFailureSentence(site, cause), { cause });
75
+ this.name = "FsFailure";
76
+ this.landing = site.landing;
77
+ this.code = errnoOf(cause);
78
+ }
79
+ }
80
+ /** 取一次失败的句子:{@link FsFailure}(已在更细的落点铸过)原样用,别的错按调用方所在的口 `site` 铸。 */
81
+ export function fsFailureMessage(site, err) {
82
+ return err instanceof FsFailure ? err.message : fsFailureSentence(site, err);
83
+ }
@@ -14,7 +14,7 @@ import { writeFileAtomic } from "./atomic-write.js";
14
14
  import { FileConfigStore } from "./file-store.js";
15
15
  import { listRequiredEnvNames } from "./file-edit.js";
16
16
  import {} from "./config-fns.js";
17
- import { configDDocumentText, judgeRemoteExecText, parseRemoteExecDocument } from "./config-d-file.js";
17
+ import { configDDocumentText, judgeConfigDText, parseRemoteExecDocument } from "./config-d-file.js";
18
18
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
19
19
  /** The remoteExec file path under a config root (under config.d/, beside the per-domain files). */
20
20
  export function remoteExecPath(root) {
@@ -23,9 +23,10 @@ export function remoteExecPath(root) {
23
23
  /** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if the file is absent
24
24
  * (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file. */
25
25
  export async function loadRemoteExec(root) {
26
+ const file = remoteExecPath(root);
26
27
  let text;
27
28
  try {
28
- text = await readFile(remoteExecPath(root), "utf8");
29
+ text = await readFile(file, "utf8");
29
30
  }
30
31
  catch (e) {
31
32
  if (e.code === "ENOENT")
@@ -34,9 +35,9 @@ export async function loadRemoteExec(root) {
34
35
  }
35
36
  // A present-but-empty file is a corruption/half-write, NOT "use the default" — FAIL CLOSED (an empty file
36
37
  // silently falling back to the host lane would be an isolation DOWNGRADE if the user meant local-docker).
37
- // To get the host default, the file must be ABSENT, not empty. The text judge and the sanitized value judge are the ones the
38
- // directory migration reads this file with (`./config-d-file`).
39
- return parseRemoteExecDocument(judgeRemoteExecText(remoteExecPath(root), text));
38
+ // To get the host default, the file must be ABSENT, not empty. The text judge is THE config.d one (empty / bad JSON / not an object, one sentence
39
+ // each — the same judge FileConfigStore and the directory migration read with); the sanitized value judge is the migration's too (`./config-d-file`).
40
+ return parseRemoteExecDocument(judgeConfigDText(REMOTE_EXEC_DOCUMENT, text, file));
40
41
  }
41
42
  /** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (the package's one atomic writer, `./atomic-write`; never a half file). The
42
43
  * client's `config` editor writes the exec backend through this; the engine reads it via loadRemoteExec. */
@@ -6,7 +6,7 @@ import type { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
6
6
  import type { DomainName, RetiredDomainName } from "./types.js";
7
7
  /** 本版登记的一次性**数据**迁移 id(闭集;每个 id 在 `ONE_TIME_MIGRATIONS` 登记表里恰一行)。收据只许这些 id —— 同表登记的 wire 删码位
8
8
  * (`WIRE_RETIREMENT_IDS`,`./one-time-migrations`)不改任何存储形,铸不出收据,不在本集。 */
9
- export declare const ONE_TIME_MIGRATION_IDS: readonly ["SS-M1", "SS-M2", "SS-M3", "SS-M4", "SS-M5", "SS-M6", "SS-M7", "SS-M9"];
9
+ export declare const ONE_TIME_MIGRATION_IDS: readonly ["SS-M1", "SS-M2", "SS-M3", "SS-M4", "SS-M5", "SS-M6", "SS-M7", "SS-M8", "SS-M9"];
10
10
  export type OneTimeMigrationId = (typeof ONE_TIME_MIGRATION_IDS)[number];
11
11
  /**
12
12
  * 一条收据说迁移**对一个坐标做了什么**(闭集,每词一义):
@@ -1,6 +1,6 @@
1
1
  /** 本版登记的一次性**数据**迁移 id(闭集;每个 id 在 `ONE_TIME_MIGRATIONS` 登记表里恰一行)。收据只许这些 id —— 同表登记的 wire 删码位
2
2
  * (`WIRE_RETIREMENT_IDS`,`./one-time-migrations`)不改任何存储形,铸不出收据,不在本集。 */
3
- export const ONE_TIME_MIGRATION_IDS = ["SS-M1", "SS-M2", "SS-M3", "SS-M4", "SS-M5", "SS-M6", "SS-M7", "SS-M9"];
3
+ export const ONE_TIME_MIGRATION_IDS = ["SS-M1", "SS-M2", "SS-M3", "SS-M4", "SS-M5", "SS-M6", "SS-M7", "SS-M8", "SS-M9"];
4
4
  /**
5
5
  * 一条收据说迁移**对一个坐标做了什么**(闭集,每词一义):
6
6
  * - `moved` — 值搬到新位(`to`),义不变;
@@ -39,7 +39,7 @@ export type StoredDocuments = Readonly<Partial<Record<DomainName | RetiredDomain
39
39
  export interface StoredDocumentsMigration {
40
40
  /** 迁移后的文档集:没改的文档是**同一引用**;被移除的(retired 域)不在;新建的(例:迁出的 `governance` / `limits`)在。 */
41
41
  readonly docs: Readonly<Record<string, unknown>>;
42
- /** 逐条收据(步序 SS-M1 → SS-M2 → SS-M5 → SS-M6 → SS-M9 → SS-M7 → SS-M4:纯改写步在前,用新 schema 终判的 SS-M4 最后;步内按坐标)。
42
+ /** 逐条收据(步序 SS-M1 → SS-M2 → SS-M5 → SS-M6 → SS-M8 → SS-M9 → SS-M7 → SS-M4:纯改写步在前,用新 schema 终判的 SS-M4 最后;步内按坐标)。
43
43
  * 只有 `refused` 的收据 = 什么都没改。 */
44
44
  readonly receipts: readonly MigrationReceipt[];
45
45
  }
@@ -34,6 +34,7 @@ import { LANE_ENV_OWNER, STORE_ENV_KEYS, isLaneEnvKey } from "./execution-lane-i
34
34
  import { EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, STORE_BACKENDS } from "./execution-lanes.js";
35
35
  import { INTEGER } from "./integer.js";
36
36
  import { RUNTIME_LIFT_TARGETS } from "./runtime-lift-targets.js";
37
+ import { RETIRED_ROLE_WORDS } from "./retired-role-words.js";
37
38
  import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
38
39
  import { DOMAIN_SCHEMAS, HOSTS_ARRAY_MAX, HOST_NAME_MAX, NON_SECRET_URL, isDomainName, isRetiredDomainName } from "./types.js";
39
40
  import { workerRowVerdict } from "./worker-row-verdict.js";
@@ -134,6 +135,19 @@ export const ONE_TIME_MIGRATIONS = [
134
135
  removal: "the next major after the window: delete the SS-M7 step and its schema walk; the NON_SECRET_URL fragment rule loses its SS-M7 clause",
135
136
  afterRemoval: "the NON_SECRET_URL shape judge, which refuses a fragment (`#` included) on read and write",
136
137
  },
138
+ {
139
+ id: "SS-M8",
140
+ oldForm: "refused",
141
+ introducedIn: "12.0.0",
142
+ releasedOn: "2026-10-12",
143
+ removableAfter: "2026-11-11",
144
+ rewrites: "models / rosters: role words removed by core 7.49.0: team, synthesize — the keys roles.team / roles.synthesize (models.roles and every " +
145
+ "rosters[].roles) are removed whatever their value (12.0.0 drops both words from ModelRole: no role reads them, and the engine refuses " +
146
+ "them); every other role and key stays as stored",
147
+ removal: "the next major after the window: delete the SS-M8 step; the retired role-word tombstone stays — its SS-M8 clause goes",
148
+ afterRemoval: "the retired role-word tombstone (permanent): a residual roles.team / roles.synthesize is refused on read and write (read: the entry is dropped " +
149
+ "when other roles survive, else models degrades to its default; rosters: the scope fails to read)",
150
+ },
137
151
  {
138
152
  id: "SS-M9",
139
153
  oldForm: "refused",
@@ -189,6 +203,7 @@ export function migrateStoredDocuments(docs, opts) {
189
203
  stepHostsBounds(out, receipts);
190
204
  stepModelKeyOneOf(out, receipts);
191
205
  stepAtMention(out, receipts);
206
+ stepRetiredRoleWords(out, receipts);
192
207
  stepModelsDefault(out, receipts);
193
208
  stepUrlFragments(out, receipts);
194
209
  stepWorkers6x(out, receipts);
@@ -675,6 +690,45 @@ function stepAtMention(out, receipts) {
675
690
  out.rosters = { ...rosters, rosters: next };
676
691
  }
677
692
  }
693
+ // ── SS-M8:退役的角色词 ────────────────────────────────────────────────────────────────────────────────
694
+ /** 一个 `roles` 图上的退役角色词键删掉(值不论 —— 没有角色读它);逐键一张 `dropped-inert` 收据。返回改后的图或 `undefined`(没改 / 不是对象:判形是读者的事)。 */
695
+ function withoutRetiredRoles(roles, document, at, receipts) {
696
+ if (!isPlainObject(roles))
697
+ return undefined;
698
+ const present = RETIRED_ROLE_WORDS.filter((w) => Object.prototype.hasOwnProperty.call(roles, w));
699
+ if (present.length === 0)
700
+ return undefined;
701
+ let next = roles;
702
+ for (const w of present) {
703
+ next = without(next, w);
704
+ receipts.push(receipt("SS-M8", document, [...at, "roles", w], "dropped-inert"));
705
+ }
706
+ return next;
707
+ }
708
+ function stepRetiredRoleWords(out, receipts) {
709
+ const models = out.models;
710
+ if (isPlainObject(models)) {
711
+ const roles = withoutRetiredRoles(models.roles, "models", [], receipts);
712
+ if (roles !== undefined)
713
+ out.models = { ...models, roles };
714
+ }
715
+ const rosters = out.rosters;
716
+ if (isPlainObject(rosters) && Array.isArray(rosters.rosters)) {
717
+ const rows = rosters.rosters;
718
+ let next;
719
+ rows.forEach((row, index) => {
720
+ if (!isPlainObject(row))
721
+ return;
722
+ const roles = withoutRetiredRoles(row.roles, "rosters", ["rosters", index], receipts);
723
+ if (roles === undefined)
724
+ return;
725
+ next ??= [...rows];
726
+ next[index] = { ...row, roles };
727
+ });
728
+ if (next !== undefined)
729
+ out.rosters = { ...rosters, rosters: next };
730
+ }
731
+ }
678
732
  // ── SS-M9:models.default → roles.default ──────────────────────────────────────────────────────────────
679
733
  /** 迁移前「缺省模型是谁」的生效读法 = server 的缺省解析以结构键 `default` 为第一级,主任务真跑它(N-7 两答复现:`default` = A、`roles.default`
680
734
  * = B 时主任务跑 A,core 的 `default` 角色解到 B)。所以并过去的是 `default` 的值:`roles.default` 缺席 ⇒ 搬入;同名 ⇒ 搬入(重复的那一份
@@ -13,7 +13,7 @@
13
13
  import type { AtMention, EffectiveConfig, EffectiveWire, ModelRole, RoleTarget, RosterEntry } from "./types.js";
14
14
  import { type RefIssue } from "./cross-domain.js";
15
15
  /** primaryModel 标记喂的角色族(§3)。 */
16
- export declare const ROSTER_PRIMARY_ROLES: readonly ["default", "team", "synthesize"];
16
+ export declare const ROSTER_PRIMARY_ROLES: readonly ["default"];
17
17
  /** cheapModel 标记喂的角色族(§3)。 */
18
18
  export declare const ROSTER_CHEAP_ROLES: readonly ["subagent", "summarize"];
19
19
  /** 把 roster 的主/cheap 角色标记派生成 roles 片段(PURE)。显式 roster.roles 逐角色盖过它;它盖过全局
@@ -2,12 +2,12 @@ import { stableHash } from "./hash.js";
2
2
  import { danglingRefs } from "./cross-domain.js";
3
3
  import { toSkillsManifest } from "./skills-manifest.js";
4
4
  // ── 主/cheap 角色标记语义(0.6.3, E3)────────────────────────────────
5
- // service 现有 ModelRoles 口径:default/team/synthesize→主、subagent/summarize→cheap。1.8.0 起 `classifier`/`consolidate`
5
+ // service 现有 ModelRoles 口径:default→主(12.0.0 起唯一;`team` / `synthesize` 随 core 7.49.0 删)、subagent/summarize→cheap。1.8.0 起 `classifier`/`consolidate`
6
6
  // 同 `verifier` 一样**不派生**(归 cheap 会盖掉 models.tiers 档位与 core 兜底链)。`verifier` 刻意
7
7
  // 不派生(unset→core 的 default 兜底链,与 service 语义一致)。常量导出=单一语义源,service 收敛小单与
8
8
  // web-admin 表单同源引用,别第二份。
9
9
  /** primaryModel 标记喂的角色族(§3)。 */
10
- export const ROSTER_PRIMARY_ROLES = ["default", "team", "synthesize"];
10
+ export const ROSTER_PRIMARY_ROLES = ["default"];
11
11
  /** cheapModel 标记喂的角色族(§3)。 */
12
12
  export const ROSTER_CHEAP_ROLES = ["subagent", "summarize"];
13
13
  /** 把 roster 的主/cheap 角色标记派生成 roles 片段(PURE)。显式 roster.roles 逐角色盖过它;它盖过全局
@@ -86,7 +86,7 @@ export function resolveEffectiveForWorker(eff, worker) {
86
86
  // `{select}`-form (capability-based) roles name no model → kept.
87
87
  // Per-role merge (roster overrides global PER ROLE) — NOT a whole-map replace. The types promise "unset
88
88
  // roster roles fall back to global models.roles per role"; a whole-map `??` silently dropped the global
89
- // `subagent`/`team`/… when a roster set only `default`. Round-3 fix. Then drop any dangling {model}.
89
+ // `subagent`/`summarize`/… when a roster set only `default`. Round-3 fix. Then drop any dangling {model}.
90
90
  // 0.6.3: marker-derived roles (primaryModel/cheapModel semantics) sit BETWEEN global and explicit
91
91
  // roster.roles — global < markers < explicit per-role. Markers are subset-validated at PUT; a stale marker
92
92
  // after a catalog change still falls through the dangling-{model} drop below (defence-in-depth).
@@ -0,0 +1,5 @@
1
+ /** 退役的角色词(闭集;逐字)。 */
2
+ export declare const RETIRED_ROLE_WORDS: readonly ["team", "synthesize"];
3
+ /** 一组被判官拒下的角色键 / 角色词里若有退役词 ⇒ 那一句(点名退役词与 SS-M8;同组里别的不认识的词也点名,一个不漏);没有 ⇒ `undefined`
4
+ * (交回 zod 自己的句子)。`ModelRole` 的判句(一个词)与 `roles` 图的未知键判句(一组键)都调这一只。 */
5
+ export declare function retiredRoleWordsRefusal(words: readonly unknown[]): string | undefined;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表):12.0.0 退役的**角色词**墓碑 —— 引擎 core 7.49.0 把 `team` / `synthesize` 从角色表里删了(对未知角色词响亮拒),
3
+ * 本包的 `ModelRole` 随之收成 6 词。墓碑是**拒**,不是第二种读法:判官遇到这两个词不把它们读成任何角色,只说「这是一份没迁的旧形」以及去哪儿迁
4
+ * (一次性迁移 SS-M8,`./one-time-migrations`)。一处写词、一处写句:`ModelRole` 的判句、`roles` 图的未知键判句与 SS-M8 的改写步都读这里。
5
+ * 迁移族按登记表的窗整族删,墓碑留下 —— 到那时句子里的迁移指路由删码那一车去掉(登记表 SS-M8 行的 `removal`)。
6
+ */
7
+ import { retiredFormSentence } from "./retired-form.js";
8
+ /** 退役的角色词(闭集;逐字)。 */
9
+ export const RETIRED_ROLE_WORDS = ["team", "synthesize"];
10
+ const ROLE_WORDS_RETIRED = {
11
+ retiredIn: "12.0.0",
12
+ now: "no role reads it (core 7.49.0 removed the role and refuses the word)",
13
+ migration: "SS-M8",
14
+ };
15
+ const isRetiredRoleWord = (w) => RETIRED_ROLE_WORDS.some((r) => r === w);
16
+ /** 一组被判官拒下的角色键 / 角色词里若有退役词 ⇒ 那一句(点名退役词与 SS-M8;同组里别的不认识的词也点名,一个不漏);没有 ⇒ `undefined`
17
+ * (交回 zod 自己的句子)。`ModelRole` 的判句(一个词)与 `roles` 图的未知键判句(一组键)都调这一只。 */
18
+ export function retiredRoleWordsRefusal(words) {
19
+ const retired = RETIRED_ROLE_WORDS.filter((r) => words.includes(r));
20
+ if (retired.length === 0)
21
+ return undefined;
22
+ const others = words.filter((w) => !isRetiredRoleWord(w)).map((w) => JSON.stringify(String(w)));
23
+ const head = others.length === 0 ? "" : `unrecognized role ${others.length === 1 ? "word" : "words"} ${others.join(", ")}; `;
24
+ return head + retiredFormSentence(`role word ${retired.join(" / ")}`, ROLE_WORDS_RETIRED);
25
+ }
package/dist/types.d.ts CHANGED
@@ -204,8 +204,6 @@ export declare const ModelRole: z.ZodEnum<{
204
204
  default: "default";
205
205
  summarize: "summarize";
206
206
  subagent: "subagent";
207
- team: "team";
208
- synthesize: "synthesize";
209
207
  verifier: "verifier";
210
208
  classifier: "classifier";
211
209
  consolidate: "consolidate";
@@ -318,8 +316,6 @@ export declare const ModelsConfig: z.ZodObject<{
318
316
  default: "default";
319
317
  summarize: "summarize";
320
318
  subagent: "subagent";
321
- team: "team";
322
- synthesize: "synthesize";
323
319
  verifier: "verifier";
324
320
  classifier: "classifier";
325
321
  consolidate: "consolidate";
@@ -375,8 +371,6 @@ export declare const RosterEntry: z.ZodObject<{
375
371
  default: "default";
376
372
  summarize: "summarize";
377
373
  subagent: "subagent";
378
- team: "team";
379
- synthesize: "synthesize";
380
374
  verifier: "verifier";
381
375
  classifier: "classifier";
382
376
  consolidate: "consolidate";
@@ -421,8 +415,6 @@ export declare const RostersConfig: z.ZodObject<{
421
415
  default: "default";
422
416
  summarize: "summarize";
423
417
  subagent: "subagent";
424
- team: "team";
425
- synthesize: "synthesize";
426
418
  verifier: "verifier";
427
419
  classifier: "classifier";
428
420
  consolidate: "consolidate";
@@ -2334,8 +2326,6 @@ export declare const DOMAIN_SCHEMAS: {
2334
2326
  default: "default";
2335
2327
  summarize: "summarize";
2336
2328
  subagent: "subagent";
2337
- team: "team";
2338
- synthesize: "synthesize";
2339
2329
  verifier: "verifier";
2340
2330
  classifier: "classifier";
2341
2331
  consolidate: "consolidate";
@@ -2391,8 +2381,6 @@ export declare const DOMAIN_SCHEMAS: {
2391
2381
  default: "default";
2392
2382
  summarize: "summarize";
2393
2383
  subagent: "subagent";
2394
- team: "team";
2395
- synthesize: "synthesize";
2396
2384
  verifier: "verifier";
2397
2385
  classifier: "classifier";
2398
2386
  consolidate: "consolidate";
package/dist/types.js CHANGED
@@ -30,6 +30,7 @@ import { Sha256Digest } from "./sha256.js"; // the one `sha256:<hex>` digest for
30
30
  import { EXECUTION_LANES, EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, SESSION_BACKENDS, STORE_BACKENDS, retiredLaneWordRefusal } from "./execution-lanes.js"; // 7.0.0 M-R2: the lane / store vocabulary (one exported table)
31
31
  import { LANE_ENV_OWNER, isMigratableEnvKey, laneEnvSeat, laneRowOf } from "./execution-lane-index.js"; // package-private indexes derived from that table (not in the barrel)
32
32
  import { migrationSignpost, retiredFormSentence, retiredKey } from "./retired-form.js"; // 11.0.0: the ONE retired-form sentence + key tombstone (package-private)
33
+ import { retiredRoleWordsRefusal } from "./retired-role-words.js"; // 12.0.0: the retired role-word tombstone (package-private; SS-M8)
33
34
  import { buildQuotedIssue, fixed } from "./quoted-issue.js"; // THE minting point of custom issues (package-private)
34
35
  import { AbsoluteHostPath } from "./host-path.js"; // the one host-path shape judge (a task's cwd and a project's folder / file entries)
35
36
  import { RFC3339_DATE_TIME } from "./rfc3339.js"; // the package's one RFC 3339 date-time judge (package-private)
@@ -550,20 +551,31 @@ export const ModelEntry = z.object({
550
551
  // `verifier` (2026-06-11, SERVICE-RESPONSE-…-PHASE2): real consumer = core's verification gate
551
552
  // (runWithVerification/resumeWithVerification resolve the verifier sub-task model via this role). Fallback
552
553
  // semantics per core: a roster that doesn't set `verifier` uses `default`.
553
- // 1.8.0(消费方壳侧真案):对齐 core `ModelRole`(9 词)里**有真消费方**的两个缺席角色 ——
554
- // `classifier` = auto 模式权限分类器(core FALLBACK classifier→summarize→default;tier 缺省 flash);此前 settings
555
- // 写 `roles.classifier` 被本闭集拒,用户想单独钉分类器模型只能连带 summarize。
554
+ // 1.8.0(消费方壳侧真案):对齐 core 角色表里**有真消费方**的两个角色 ——
555
+ // `classifier` = auto 模式权限分类器(core FALLBACK classifier→summarize→default;tier 缺省 flash);
556
556
  // `consolidate` = 记忆整理(core FALLBACK consolidate→summarize)。
557
557
  // 两个词都**不进** roster 标记族(resolve-roster.ts):显式归 cheap 会让 server 把它钉死到 cheapRef,盖掉用户
558
558
  // `models.tiers.flash` 的档位与 core 兜底链 —— 新角色「不派生、走 core 兜底、只由 settings 显式钉」。
559
- // `advisor`(teacher mode)仍 deliberately NOT added: no consumer, no key —— 谁消费谁来加。
560
- // 子集关系由消费方服务端的型面对账钉(编译期 `satisfies`)钉住(本仓不依赖 core)。
561
- export const ModelRole = z.enum(["default", "summarize", "subagent", "team", "synthesize", "verifier", "classifier", "consolidate"]);
559
+ // 12.0.0(硬破坏):`team` / `synthesize` 随 core 7.49.0 删掉(引擎对未知角色词响亮拒)—— 零别名、零双读;存着的这两个键由一次性迁移 SS-M8 删,
560
+ // 迁移前到达判官的旧词拒句点名它(墓碑在 `./retired-role-words`)。`advisor`(teacher mode)本包从未收(没有消费方)。
561
+ // 子集关系(本表 ⊆ 引擎角色表)由消费方服务端的型面对账钉住(本仓不依赖 core;dev 期的漂移门 ② 对拍 devDependency 那一版引擎)。
562
+ export const ModelRole = z.enum(["default", "summarize", "subagent", "verifier", "classifier", "consolidate"], {
563
+ error: (iss) => (iss.code === "invalid_value" ? retiredRoleWordsRefusal([iss.input]) : undefined),
564
+ });
562
565
  /** A role resolves either to a model NAME, or by capability/price (core 1.25 `{ select }`). */
563
566
  export const RoleTarget = z.union([
564
567
  z.object({ model: z.string().min(1) }),
565
568
  z.object({ select: z.object({ tier: ModelTier.optional(), needs: z.array(z.enum(["vision", "reasoning"])).optional(), prefer: z.enum(["cheapest", "premium"]).optional() }) }),
566
569
  ]);
570
+ /** 角色 → 目标的图(`models.roles` 与 `rosters[].roles` 同一只)。键不认识 ⇒ zod 的「未知键」句;键里有 12.0.0 退役的角色词 ⇒ 那一句点名
571
+ * 一次性迁移 SS-M8(同组别的未知键一并点名)。 */
572
+ const RoleMap = z.partialRecord(ModelRole, RoleTarget, {
573
+ // 枚举键的 record 在运行期以 `unrecognized_keys`(带 `keys`)报未知键,zod 的型面只列了 invalid_type / invalid_key —— 按运行期形读(钉:role-words-12.0.0)。
574
+ error: (iss) => {
575
+ const raw = iss;
576
+ return raw.code === "unrecognized_keys" && Array.isArray(raw.keys) ? retiredRoleWordsRefusal(raw.keys) : undefined;
577
+ },
578
+ });
567
579
  /** {@link AtMention} 的模式闭集:`all` = 每个启用的模型都可 `@`;`none` = 一个都不许;`only` = 只许 `models` 里点名的那几个。 */
568
580
  export const AT_MENTION_MODES = ["all", "none", "only"];
569
581
  /** 「`@` 能点名哪些模型」(11.0.0,取代 `atModelAllowlist`)。义由 `mode` 说,不由数组空不空说:旧形一个数组靠「缺席 / 空 / 非空」
@@ -609,7 +621,7 @@ export const ModelsConfig = z
609
621
  * the main task ran the first and the core `default` role resolved the second). Which catalog entry is the default is
610
622
  * `resolveCatalogDefaultModel` (`./default-model`; a `{select}` default is handed back as-is for the engine to resolve). A stored
611
623
  * `default` key is moved here by the one-time migration SS-M9. */
612
- roles: z.partialRecord(ModelRole, RoleTarget).prefault({}),
624
+ roles: RoleMap.prefault({}),
613
625
  /** Which model names an end user may `@`-mention({@link AtMention});缺省 `{mode:"all"}`。 */
614
626
  atMention: AtMention.prefault({ mode: "all" }),
615
627
  /** retired 键墓碑(11.0.0):在场即拒,拒句点名一次性迁移 SS-M6。 */
@@ -675,7 +687,7 @@ export const RosterEntry = z.object({
675
687
  models: z.array(z.string().min(1)).prefault([]),
676
688
  /**
677
689
  * 主模型角色标记(0.6.3, E3)——catalog model NAME。worker.model 废除后 service 从 roster
678
- * 解析 boot 主模型的归宿位:解析语义(resolve-roster)= primary 喂 `default`/`team`/`synthesize` 三角色、
690
+ * 解析 boot 主模型的归宿位:解析语义(resolve-roster)= primary 喂 `default`(12.0.0 起唯一;`team` / `synthesize` 随 core 7.49.0 删)、
679
691
  * cheap 喂 `subagent`/`summarize`(service 现有 ModelRoles 口径;`verifier`/`classifier`/`consolidate` 不派生——unset 走 core 兜底链)。
680
692
  * 显式 `roles` **逐角色**优先于标记派生;标记优先于全局 models.roles。additive:不设=行为与 0.6.2 全等。
681
693
  */
@@ -683,7 +695,7 @@ export const RosterEntry = z.object({
683
695
  /** cheap 模型角色标记(同上;worker.model.cheapId 的迁移归宿,§3②)。 */
684
696
  cheapModel: z.string().min(1).optional(),
685
697
  /** Role map override; unset roles fall back to marker-derived roles, then the global models.roles. */
686
- roles: z.partialRecord(ModelRole, RoleTarget).optional(),
698
+ roles: RoleMap.optional(),
687
699
  /** `@` 点名覆盖({@link AtMention});缺席 = 继承全局 `models.atMention`。 */
688
700
  atMention: AtMention.optional(),
689
701
  /** retired 键墓碑(11.0.0):在场即拒,拒句点名一次性迁移 SS-M6。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/settings-schema",
3
- "version": "11.3.0",
3
+ "version": "12.0.1",
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",