@sema-agent/settings-schema 10.1.1 → 11.1.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.
Files changed (240) hide show
  1. package/CHANGELOG.md +469 -1
  2. package/README.md +144 -66
  3. package/bin/settings-schema.mjs +10 -12
  4. package/conformance/VERSION +1 -1
  5. package/conformance/baseline.json +3 -1
  6. package/conformance/cases/c01-entry-salvage-then-typo/expected.json +3 -1
  7. package/conformance/cases/c02-hosts-clamp-stacked/expected.json +12 -5
  8. package/conformance/cases/c02-hosts-clamp-stacked/meta.json +1 -1
  9. package/conformance/cases/c03-literal-dotted-top-key/expected.json +22 -2
  10. package/conformance/cases/c04-all-bad-collection-not-salvaged/expected.json +3 -1
  11. package/conformance/cases/c06-legacy-governance-lift/expected.json +11 -101
  12. package/conformance/cases/c06-legacy-governance-lift/meta.json +1 -1
  13. package/conformance/cases/c07-hooks-is-not-a-config-domain/expected.json +3 -1
  14. package/conformance/cases/c08-limits-unknown-carried-64/expected.json +203 -2
  15. package/conformance/cases/c09-limits-unknown-carried-65/expected.json +204 -2
  16. package/conformance/cases/c10-record-key-secret-redacted-twice/expected.json +3 -1
  17. package/conformance/cases/c11-unicode-unknown-keys/expected.json +20 -2
  18. package/conformance/cases/c12-prototype-name-top-keys/expected.json +3 -1
  19. package/conformance/cases/c13-models-field-null/expected.json +3 -1
  20. package/conformance/cases/c14-models-field-empty-array/expected.json +6 -1
  21. package/conformance/cases/c15-models-field-number/expected.json +3 -1
  22. package/conformance/cases/c16-deep-nesting-205/expected.json +14 -2
  23. package/conformance/cases/c17-empty-domain-file/expected.json +3 -1
  24. package/conformance/cases/c18-wire-envelope-not-an-object/expected.json +3 -1
  25. package/conformance/cases/c19-wire-envelope-no-version/expected.json +3 -1
  26. package/conformance/cases/c20-governance-absent-lifts/expected.json +11 -86
  27. package/conformance/cases/c20-governance-absent-lifts/meta.json +1 -1
  28. package/conformance/cases/c21-governance-empty-object-wins/expected.json +6 -5
  29. package/conformance/cases/c21-governance-empty-object-wins/input.json +1 -4
  30. package/conformance/cases/c21-governance-empty-object-wins/meta.json +1 -1
  31. package/conformance/cases/c23-mixed-validity-models/expected.json +3 -1
  32. package/conformance/cases/c24-valid-entries-with-unknown-keys/expected.json +22 -2
  33. package/conformance/cases/c25-published-roundtrip-zero-warnings/expected.json +6 -1
  34. package/conformance/cases/c26-hosts-array-1024/expected.json +3 -1
  35. package/conformance/cases/c27-hosts-array-1025/expected.json +6 -7175
  36. package/conformance/cases/c27-hosts-array-1025/meta.json +1 -1
  37. package/conformance/cases/c28-large-document-over-1mb/expected.json +14 -2
  38. package/conformance/cases/c29-salvage-then-domain-refine-fails/expected.json +3 -1
  39. package/conformance/cases/c30-version-triple-a-first/expected.json +6 -1
  40. package/conformance/cases/c31-version-triple-b-changed/expected.json +6 -1
  41. package/conformance/cases/c32-version-triple-a-restored/expected.json +6 -1
  42. package/conformance/cases/c33-limits-nested-carried-key/expected.json +18 -2
  43. package/conformance/cases/c34-skills-content-vs-manifest/expected.json +19 -3
  44. package/conformance/cases/c35-portable-domain-file-is-null/expected.json +3 -1
  45. package/conformance/cases/c36-skills-entry-without-content/expected.json +6 -2
  46. package/conformance/cases/c37-skills-field-null/expected.json +6 -2
  47. package/conformance/cases/c38-remote-exec-is-a-non-domain-file/expected.json +3 -1
  48. package/conformance/cases/c39-hidden-file-in-config-d/expected.json +3 -1
  49. package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +11 -4
  50. package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +9 -3
  51. package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +39 -2
  52. package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +4 -1
  53. package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +3 -1
  54. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +19 -10
  55. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/meta.json +1 -1
  56. package/conformance/cases/{c46-s811-host-name-url-userinfo-characterization → c46-s811-host-name-url-userinfo-withheld}/expected.json +12 -5
  57. package/conformance/cases/c46-s811-host-name-url-userinfo-withheld/meta.json +9 -0
  58. package/conformance/cases/{c47-s811-roles-key-url-userinfo-characterization → c47-s811-roles-key-url-userinfo-withheld}/expected.json +23 -3
  59. package/conformance/cases/{c47-s811-roles-key-url-userinfo-characterization → c47-s811-roles-key-url-userinfo-withheld}/input.json +6 -0
  60. package/conformance/cases/c47-s811-roles-key-url-userinfo-withheld/meta.json +10 -0
  61. package/conformance/cases/c48-s935-model-headers-transported/expected.json +6 -1
  62. package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/expected.json +3 -1
  63. package/conformance/cases/c50-s784-lane-host-legal/expected.json +3 -1
  64. package/conformance/cases/c51-s784-lane-e2b-legal/expected.json +3 -1
  65. package/conformance/cases/c52-s784-lane-k8s-legal/expected.json +3 -1
  66. package/conformance/cases/c53-s784-lane-ssh-legal/expected.json +3 -1
  67. package/conformance/cases/c54-s784-lane-adb-legal/expected.json +3 -1
  68. package/conformance/cases/c55-s784-lane-local-docker-legal/expected.json +3 -1
  69. package/conformance/cases/c56-s784-lane-device-legal/expected.json +3 -1
  70. package/conformance/cases/c57-s784-g1-enabled-no-lane/expected.json +3 -1
  71. package/conformance/cases/c58-s784-g2-multitenant-host/expected.json +3 -1
  72. package/conformance/cases/c59-s784-g3-k8s-missing-image/expected.json +3 -1
  73. package/conformance/cases/c60-s784-g3-e2b-missing-key/expected.json +3 -1
  74. package/conformance/cases/c61-s784-g3-other-lane-key/expected.json +3 -1
  75. package/conformance/cases/c62-s784-g7a-session-tidb/expected.json +3 -1
  76. package/conformance/cases/c63-s784-host-two-replicas/expected.json +3 -1
  77. package/conformance/cases/c64-s784-multitenant-local-store/expected.json +3 -1
  78. package/conformance/cases/c65-s784-pg-without-db/expected.json +3 -1
  79. package/conformance/cases/c66-s784-memory-backend-mismatch/expected.json +3 -1
  80. package/conformance/cases/c67-s784-retired-field-loud/expected.json +3 -1
  81. package/conformance/cases/c67-s784-retired-field-loud/meta.json +1 -1
  82. package/conformance/cases/c68-s784-device-local-store/expected.json +3 -1
  83. package/conformance/cases/c69-deep-encoded-token-past-every-reading/expected.json +3 -1
  84. package/conformance/cases/c70-rewritten-token-in-front-of-encoded-pem-header/expected.json +3 -1
  85. package/conformance/cases/c71-one-scanner-write-reads-display-forms/expected.json +119 -0
  86. package/conformance/cases/c71-one-scanner-write-reads-display-forms/input.json +24 -0
  87. package/conformance/cases/c71-one-scanner-write-reads-display-forms/meta.json +10 -0
  88. package/conformance/cases/c72-dangling-reference-write-seat-refuses/expected.json +111 -0
  89. package/conformance/cases/c72-dangling-reference-write-seat-refuses/input.json +26 -0
  90. package/conformance/cases/c72-dangling-reference-write-seat-refuses/meta.json +6 -0
  91. package/conformance/cases/cc01-mcp-toolfaces-non-object/expected.json +3 -1
  92. package/conformance/cases/cc02-mcp-source-key-is-transported/expected.json +3 -1
  93. package/conformance/cases/cc03-model-compat-unknown-subkey/expected.json +6 -1
  94. package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +11 -1
  95. package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +11 -1
  96. package/conformance/cases/cli03-credential-pasted-into-unknown-key/expected.json +16 -2
  97. package/conformance/cases/cli04-env-value-with-newline/expected.json +3 -1
  98. package/conformance/cases/cli05-long-and-prefix-colliding-names/expected.json +6 -1
  99. package/conformance/cases/t01-absent-domain-key-not-null/expected.json +3 -1
  100. package/conformance/cases/t03-prototype-name-key-not-swallowed/expected.json +29 -2
  101. package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +3 -1
  102. package/conformance/cases/z01-every-domain-empty-object/expected.json +8 -4
  103. package/conformance/cases/z01-every-domain-empty-object/input.json +0 -1
  104. package/conformance/cases/z01-every-domain-empty-object/meta.json +1 -1
  105. package/conformance/cases/z02-every-domain-unknown-key/expected.json +163 -23
  106. package/conformance/cases/z02-every-domain-unknown-key/input.json +0 -3
  107. package/conformance/cases/z02-every-domain-unknown-key/meta.json +1 -1
  108. package/dist/api/auth-bridge.d.ts +51 -181
  109. package/dist/api/auth-bridge.js +58 -33
  110. package/dist/api/auth.d.ts +37 -146
  111. package/dist/api/auth.js +16 -26
  112. package/dist/api/feedback.d.ts +87 -0
  113. package/dist/api/feedback.js +66 -0
  114. package/dist/api/pull-token.d.ts +32 -0
  115. package/dist/api/pull-token.js +88 -0
  116. package/dist/api/scopes.d.ts +82 -174
  117. package/dist/api/scopes.js +23 -18
  118. package/dist/atomic-write.d.ts +14 -0
  119. package/dist/atomic-write.js +60 -0
  120. package/dist/bundle.d.ts +15 -7
  121. package/dist/bundle.js +28 -27
  122. package/dist/cli.d.ts +3 -1
  123. package/dist/cli.js +149 -80
  124. package/dist/config-d-file.d.ts +14 -0
  125. package/dist/config-d-file.js +67 -0
  126. package/dist/config-fns.d.ts +87 -186
  127. package/dist/config-fns.js +207 -262
  128. package/dist/conformance/contract-adapter.d.ts +9 -2
  129. package/dist/conformance/contract-adapter.js +15 -7
  130. package/dist/conformance/corpus.d.ts +3 -0
  131. package/dist/conformance/corpus.js +5 -0
  132. package/dist/conformance/derive.d.ts +3 -0
  133. package/dist/conformance/index.d.ts +2 -2
  134. package/dist/conformance/index.js +2 -2
  135. package/dist/conformance/run.js +67 -22
  136. package/dist/conformance/types.d.ts +31 -15
  137. package/dist/conformance/types.js +5 -0
  138. package/dist/conformance/warnings.d.ts +1 -1
  139. package/dist/conformance/warnings.js +1 -4
  140. package/dist/cross-domain.d.ts +66 -22
  141. package/dist/cross-domain.js +164 -93
  142. package/dist/cross-session.d.ts +29 -11
  143. package/dist/default-model.d.ts +43 -0
  144. package/dist/default-model.js +34 -0
  145. package/dist/execution-lane-index.d.ts +4 -8
  146. package/dist/execution-lane-index.js +4 -8
  147. package/dist/execution-lanes.d.ts +31 -8
  148. package/dist/execution-lanes.js +23 -5
  149. package/dist/file-edit.d.ts +3 -3
  150. package/dist/file-edit.js +11 -18
  151. package/dist/file-store.d.ts +7 -6
  152. package/dist/file-store.js +37 -50
  153. package/dist/fleet.d.ts +364 -762
  154. package/dist/fleet.js +277 -224
  155. package/dist/hooks.d.ts +1151 -4737
  156. package/dist/hooks.js +59 -15
  157. package/dist/index.d.ts +10 -4
  158. package/dist/index.js +13 -7
  159. package/dist/integer.d.ts +11 -0
  160. package/dist/integer.js +11 -0
  161. package/dist/issue-path.d.ts +10 -0
  162. package/dist/issue-path.js +10 -0
  163. package/dist/local-load.d.ts +1 -1
  164. package/dist/local-load.js +4 -14
  165. package/dist/loopback.d.ts +26 -0
  166. package/dist/loopback.js +54 -0
  167. package/dist/migration-receipt.d.ts +42 -0
  168. package/dist/migration-receipt.js +12 -0
  169. package/dist/one-time-migrations.d.ts +62 -0
  170. package/dist/one-time-migrations.js +828 -0
  171. package/dist/prompts-digest.d.ts +1 -1
  172. package/dist/prompts-digest.js +3 -2
  173. package/dist/reader.d.ts +2 -2
  174. package/dist/remote-exec.d.ts +45 -187
  175. package/dist/remote-exec.js +16 -10
  176. package/dist/resolve-roster.d.ts +18 -3
  177. package/dist/resolve-roster.js +28 -25
  178. package/dist/retired-envs.d.ts +24 -0
  179. package/dist/retired-envs.js +114 -0
  180. package/dist/retired-form.d.ts +31 -0
  181. package/dist/retired-form.js +28 -0
  182. package/dist/safety-merge-spec.d.ts +131 -191
  183. package/dist/safety-merge-spec.js +18 -14
  184. package/dist/scheduler-lifetime.d.ts +5 -0
  185. package/dist/scheduler-lifetime.js +7 -0
  186. package/dist/scheduler-store-migration.d.ts +14 -0
  187. package/dist/scheduler-store-migration.js +14 -0
  188. package/dist/scheduler-store-node.d.ts +28 -12
  189. package/dist/scheduler-store-node.js +32 -23
  190. package/dist/scheduler-store.d.ts +47 -14
  191. package/dist/scheduler-store.js +32 -61
  192. package/dist/secret-refs.d.ts +3 -3
  193. package/dist/secret-refs.js +1 -1
  194. package/dist/secret-scan.d.ts +14 -24
  195. package/dist/secret-scan.js +45 -23
  196. package/dist/sha256-core.d.ts +16 -0
  197. package/dist/sha256-core.js +138 -0
  198. package/dist/sha256.d.ts +6 -1
  199. package/dist/sha256.js +8 -98
  200. package/dist/skills-manifest.js +1 -1
  201. package/dist/types.d.ts +1509 -13536
  202. package/dist/types.js +526 -438
  203. package/dist/url-shape.d.ts +34 -0
  204. package/dist/url-shape.js +61 -0
  205. package/dist/web-admin-credential.d.ts +96 -0
  206. package/dist/{center-credential.js → web-admin-credential.js} +49 -46
  207. package/dist/web-admin-env.d.ts +33 -0
  208. package/dist/web-admin-env.js +33 -0
  209. package/dist/worker-row-verdict.d.ts +16 -0
  210. package/dist/worker-row-verdict.js +31 -0
  211. package/dist/workers-rows.d.ts +34 -0
  212. package/dist/workers-rows.js +42 -0
  213. package/dist/zod-unwrap.d.ts +11 -0
  214. package/dist/zod-unwrap.js +25 -0
  215. package/package.json +17 -9
  216. package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/meta.json +0 -6
  217. package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/meta.json +0 -6
  218. package/conformance/migrate-workers/m01-g5-k8s-env-lifted/expected.json +0 -49
  219. package/conformance/migrate-workers/m01-g5-k8s-env-lifted/input.json +0 -26
  220. package/conformance/migrate-workers/m01-g5-k8s-env-lifted/meta.json +0 -4
  221. package/conformance/migrate-workers/m02-g5-idempotent/expected.json +0 -38
  222. package/conformance/migrate-workers/m02-g5-idempotent/input.json +0 -33
  223. package/conformance/migrate-workers/m02-g5-idempotent/meta.json +0 -5
  224. package/conformance/migrate-workers/m03-g5-refused-no-lane/expected.json +0 -71
  225. package/conformance/migrate-workers/m03-g5-refused-no-lane/input.json +0 -42
  226. package/conformance/migrate-workers/m03-g5-refused-no-lane/meta.json +0 -4
  227. package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/expected.json +0 -52
  228. package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/input.json +0 -30
  229. package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/meta.json +0 -4
  230. package/conformance/migrate-workers/m05-row-verdict-reserved-env/expected.json +0 -82
  231. package/conformance/migrate-workers/m05-row-verdict-reserved-env/input.json +0 -68
  232. package/conformance/migrate-workers/m05-row-verdict-reserved-env/meta.json +0 -4
  233. package/dist/center-credential.d.ts +0 -116
  234. package/dist/issue-text.d.ts +0 -10
  235. package/dist/issue-text.js +0 -8
  236. package/dist/migrate-workers.d.ts +0 -71
  237. package/dist/migrate-workers.js +0 -351
  238. package/dist/migrate.d.ts +0 -255
  239. package/dist/migrate.js +0 -542
  240. /package/conformance/cases/{c46-s811-host-name-url-userinfo-characterization → c46-s811-host-name-url-userinfo-withheld}/input.json +0 -0
@@ -4,9 +4,9 @@
4
4
  *
5
5
  * ## 为什么在这里
6
6
  * 车道词、每条车道读哪些 env、哪条车道分租户、存储后端词 —— 这几张表以前在四处各抄一份(引擎的能力表、
7
- * 部署面的车道清单、中心表单、本包 `RemoteExecSpec`),任何一处改了别处都不红。从本版起**只有这一份**:
8
- * · 中心写闸 = `WorkerSpec.safeParse`(本包 schema 读这张表判 `runtime.execution` / `runtime.store`);
9
- * · 中心渲染器按这张表把结构块渲成 env(唯一写者);
7
+ * 部署面的车道清单、web-admin 表单、本包 `RemoteExecSpec`),任何一处改了别处都不红。从本版起**只有这一份**:
8
+ * · web-admin 写闸 = `WorkerSpec.safeParse`(本包 schema 读这张表判 `runtime.execution` / `runtime.store`);
9
+ * · web-admin 渲染器按这张表把结构块渲成 env(唯一写者);
10
10
  * · 引擎提货时从这张表派生自己的车道词表 / 必需键表 / 租户分离位(引擎只留运行期事实,按导入联合类型穷举);
11
11
  * · 部署面车道清单由这张表生成。
12
12
  * 写闸与起服读同一张表 ⇒ 不会分叉。
@@ -15,7 +15,8 @@
15
15
  * · 车道词与次序:`src/execution-lane-caps.ts` `EXECUTION_LANE_WORDS`(:335)去掉 `in-process` =
16
16
  * `REMOTE_EXEC_PROVIDER_WORDS`(:366)。次序照抄,于是引擎改派生之后,拒启句里的 `valid:` 列表与出路列表逐字不变。
17
17
  * · `tenantSeparation`:同文件 `executionLaneCaps` 每臂的 `tenantSeparation` 位(:209 host / :230 e2b / :249 k8s /
18
- * :269 ssh / :288 adb / :308 local-docker / :327 device)。
18
+ * :269 ssh / :288 adb / :308 local-docker / :327 device)。`tenantSeparationBasis`(11.0.0):分租户的那几臂凭什么分 ——
19
+ * e2b / k8s / local-docker 每任务一只隔离目标(`isolation`),device 是发起者自己的设备(`principal-binding`)。
19
20
  * · `requiredEnv ∪ requiredSecretEnv`:`src/config.ts` `REQUIRED_ENVS_BY_LANE`(:1054)逐字;按 `src/config-catalog.ts`
20
21
  * 该键那一行的值类型分栏 —— 类型 `secret` / `opaque`(目录自己「绝不回显值」的两类)进 `*SecretEnv`,其余进 `*Env`。
21
22
  * · `optionalEnv ∪ optionalSecretEnv`:`src/config.ts` `parseOrchestrationDomain` 的 `REMOTE_EXEC` 三元链里**该车道那一臂**
@@ -31,12 +32,24 @@
31
32
  * 仍回 completed + 零工具,就是那个病换了个合法名字。要无手 worker 先另立设计、定用户面行为再给词。
32
33
  * · `remote-docker`:引擎有一只没接线的适配器工厂,但没有任何读点能把这个词变成一条在跑的车道
33
34
  * (`REMOTE_EXEC=remote-docker` 起服即拒;远端 docker 守护进程今天的真形 = `local-docker` + `DOCKER_HOST`)。
35
+ * 它在退役词墓碑 {@link RETIRED_LANE_WORDS} 里:读到即拒,拒句带出路。
34
36
  */
35
37
  /** 可声明的执行车道闭集(= 引擎 `REMOTE_EXEC` 收的词,逐字同序)。 */
36
38
  export const EXECUTION_LANE_WORDS = ["host", "e2b", "k8s", "ssh", "adb", "local-docker", "device"];
39
+ export const RETIRED_LANE_WORDS = Object.freeze([
40
+ Object.freeze(["remote-docker", "local-docker", "point it at the remote docker daemon with DOCKER_HOST (in a worker spec: runtime.execution.coords.DOCKER_HOST)"]),
41
+ ]);
42
+ /** 一个车道值是不是退役词:是 ⇒ 带出路的整句;否(现行词、不认识的词、非串)⇒ `undefined`。纯函数。 */
43
+ export function retiredLaneWordRefusal(word) {
44
+ const row = RETIRED_LANE_WORDS.find(([old]) => old === word);
45
+ return row === undefined ? undefined : `execution lane ${row[0]} was retired — use ${row[1]}: ${row[2]}`; // no quotes: the sentence survives a JSON-rendered issue list verbatim
46
+ }
37
47
  /** 车道分不分租户(引擎能力表的 `tenantSeparation` 位,两档,没有「不表态」)。
38
48
  * `supported` = 每个租户的命令落在各自的目标上;`single-user-only` = 所有任务落同一个共享目标 ⇒ 只许单用户 worker。 */
39
49
  export const TENANT_SEPARATION_WORDS = ["supported", "single-user-only"];
50
+ /** 分租户车道**凭什么**分租户(两档):`isolation` = 每个任务一只隔离的目标(microVM / pod / 容器);`principal-binding` = 目标绑在
51
+ * 发起者身上(设备 ↔ principal 绑定),目标本身不隔离。只有 `tenantSeparation: "supported"` 的车道有依据。 */
52
+ export const TENANT_SEPARATION_BASES = ["isolation", "principal-binding"];
40
53
  /** 逐车道一行。`satisfies Record<ExecutionLane, …>` = 词表加词而这里不加行 ⇒ 编译红。 */
41
54
  const LANE_ROWS = {
42
55
  /** server `config.ts` :4014 臂(无必需键);`execution-lane-caps.ts` :209 `single-user-only`(所有任务落 worker 宿主)。 */
@@ -51,6 +64,7 @@ const LANE_ROWS = {
51
64
  * `execution-lane-caps.ts` :230 `supported`(每任务一只 microVM)。 */
52
65
  e2b: {
53
66
  tenantSeparation: "supported",
67
+ tenantSeparationBasis: "isolation",
54
68
  requiredEnv: [],
55
69
  requiredSecretEnv: ["E2B_API_KEY"],
56
70
  optionalEnv: ["E2B_TEMPLATE", "E2B_TIMEOUT_MS", "E2B_LIVENESS_MS", "E2B_ALLOW_NET", "E2B_MOUNT_PATH", "E2B_ORPHAN_RECLAIM"],
@@ -60,6 +74,7 @@ const LANE_ROWS = {
60
74
  * `execution-lane-caps.ts` :249 `supported`(每任务一只 pod)。`MINIO_*` 刻意不收(见文件头)。 */
61
75
  k8s: {
62
76
  tenantSeparation: "supported",
77
+ tenantSeparationBasis: "isolation",
63
78
  requiredEnv: ["K8S_SANDBOX_IMAGE"],
64
79
  requiredSecretEnv: [],
65
80
  optionalEnv: [
@@ -98,6 +113,7 @@ const LANE_ROWS = {
98
113
  * (每任务一只容器)。`DOCKER_HOST` 在本行:远端 docker 守护进程 = 本车道 + `coords.DOCKER_HOST`。 */
99
114
  "local-docker": {
100
115
  tenantSeparation: "supported",
116
+ tenantSeparationBasis: "isolation",
101
117
  requiredEnv: ["DOCKER_IMAGE"],
102
118
  requiredSecretEnv: [],
103
119
  optionalEnv: [
@@ -117,6 +133,7 @@ const LANE_ROWS = {
117
133
  * 连进来的);`execution-lane-caps.ts` :327 `supported`(发起者自己的设备)。 */
118
134
  device: {
119
135
  tenantSeparation: "supported",
136
+ tenantSeparationBasis: "principal-binding",
120
137
  requiredEnv: [],
121
138
  requiredSecretEnv: [],
122
139
  optionalEnv: [
@@ -134,9 +151,10 @@ const LANE_ROWS = {
134
151
  /** 车道表(次序 = {@link EXECUTION_LANE_WORDS})。整张表深冻结 —— 它是唯一正本,消费方拿到的不许被改。 */
135
152
  export const EXECUTION_LANES = Object.freeze(EXECUTION_LANE_WORDS.map((lane) => {
136
153
  const row = LANE_ROWS[lane];
154
+ const tenancy = row.tenantSeparation === "supported" ? { tenantSeparation: row.tenantSeparation, tenantSeparationBasis: row.tenantSeparationBasis } : { tenantSeparation: row.tenantSeparation };
137
155
  return Object.freeze({
138
156
  lane,
139
- tenantSeparation: row.tenantSeparation,
157
+ ...tenancy,
140
158
  requiredEnv: Object.freeze([...row.requiredEnv]),
141
159
  requiredSecretEnv: Object.freeze([...row.requiredSecretEnv]),
142
160
  optionalEnv: Object.freeze([...row.optionalEnv]),
@@ -17,9 +17,9 @@ export declare function validateRemoteExec(raw: unknown): ValidateResult<RemoteE
17
17
  /** Absolute path to a domain's JSON file under a config root (mirrors FileConfigStore.domainPath). Guards the
18
18
  * name (no `../../secret` traversal even if a caller bypasses the DomainName type). */
19
19
  export declare function domainFilePath(root: string, domain: DomainName): string;
20
- /** WRITE a domain file ATOMICALLY (write a tmp in the same dir → rename) so a crash never leaves a half-written
21
- * config.d/<domain>.json. VALIDATES first (throws on invalid — never persists garbage); persists the parsed +
22
- * defaulted value so the file is canonical. */
20
+ /** WRITE a domain file ATOMICALLY (the package's one atomic writer, `./atomic-write`: tmp in the same dir → rename) so a crash never
21
+ * leaves a half-written config.d/<domain>.json; a new file takes the contract-document mode (0o644), an existing one keeps its own.
22
+ * VALIDATES first (throws on invalid — never persists garbage); persists the parsed + defaulted value so the file is canonical. */
23
23
  export declare function writeDomainFile<K extends DomainName>(root: string, domain: K, value: unknown): Promise<DomainConfig[K]>;
24
24
  export interface RequiredEnvName {
25
25
  envName: string;
package/dist/file-edit.js CHANGED
@@ -5,25 +5,27 @@
5
5
  *
6
6
  * Validation uses the SAME zod schemas as the server's parseDomain → identical rules on both sides.
7
7
  */
8
- import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
8
+ import { mkdir } from "node:fs/promises";
9
9
  import path from "node:path";
10
+ import { writeFileAtomic } from "./atomic-write.js";
10
11
  import { collectEnvRefs } from "./secret-refs.js";
11
12
  import { RemoteExecSpec } from "./remote-exec.js";
12
- import { parseDomain, sanitizeZodError } from "./config-fns.js";
13
- import { DOMAIN_SCHEMAS, isDomainName } from "./types.js";
13
+ import { RetiredDomainError, parseDomain, sanitizeZodError } from "./config-fns.js";
14
+ import { DOMAIN_SCHEMAS, isDomainName, isRetiredDomainName } from "./types.js";
14
15
  /** Throw a CLEAR error for a non-domain key instead of the cryptic `undefined.parse(...)` you'd get from
15
16
  * `DOMAIN_SCHEMAS[badKey]`. The classic trip-wire: a consumer scans `config.d/*.json` and feeds every file
16
17
  * (incl. the non-domain `remote-exec.json`) into writeBundle/writeDomainFile. `remote-exec` is the exec
17
18
  * backend, NOT a domain — write it via writeRemoteExec. Filter arbitrary keys with `isDomainName` first. */
18
19
  function assertDomainName(domain) {
20
+ // A retired domain is refused by its tombstone (the sentence names where its keys went), like every other reader / writer.
21
+ if (isRetiredDomainName(domain))
22
+ throw new RetiredDomainError(domain);
19
23
  if (!isDomainName(domain)) {
20
24
  const hint = domain === "remote-exec" ? " — the exec backend is NOT a domain; use writeRemoteExec (config.d/remote-exec.json)" : "";
21
25
  throw new Error(`"${domain}" is not a config domain (expected one of: ${DOMAINS_LIST})${hint}`);
22
26
  }
23
27
  }
24
28
  const DOMAINS_LIST = Object.keys(DOMAIN_SCHEMAS).join(", ");
25
- // Monotonic suffix so concurrent same-domain atomic writes in one process+ms can't collide on the tmp name.
26
- let tmpSeq = 0;
27
29
  /** VALIDATE a domain payload against its zod schema WITHOUT writing — the editor's "is this form valid?" check.
28
30
  * Returns the parsed+defaulted value or the zod issues. Identical rules to the server's parseDomain. Throws
29
31
  * (clear message) if `domain` is not a real domain name (a misuse, not invalid user data). */
@@ -58,24 +60,15 @@ export function domainFilePath(root, domain) {
58
60
  assertDomainName(domain);
59
61
  return path.join(root, "config.d", `${domain}.json`);
60
62
  }
61
- /** WRITE a domain file ATOMICALLY (write a tmp in the same dir → rename) so a crash never leaves a half-written
62
- * config.d/<domain>.json. VALIDATES first (throws on invalid — never persists garbage); persists the parsed +
63
- * defaulted value so the file is canonical. */
63
+ /** WRITE a domain file ATOMICALLY (the package's one atomic writer, `./atomic-write`: tmp in the same dir → rename) so a crash never
64
+ * leaves a half-written config.d/<domain>.json; a new file takes the contract-document mode (0o644), an existing one keeps its own.
65
+ * VALIDATES first (throws on invalid — never persists garbage); persists the parsed + defaulted value so the file is canonical. */
64
66
  export async function writeDomainFile(root, domain, value) {
65
67
  assertDomainName(domain); // a stray non-domain key (e.g. "remote-exec" from a config.d scan) fails LOUD + clear
66
68
  const parsed = parseDomain(domain, value); // routes through the SANITIZED parser (explicit-null reject R4 + no secret echo R14)
67
69
  const dir = path.join(root, "config.d");
68
70
  await mkdir(dir, { recursive: true });
69
- const dest = path.join(dir, `${domain}.json`);
70
- const tmp = path.join(dir, `.${domain}.json.${process.pid}.${Date.now()}.${tmpSeq++}.tmp`);
71
- try {
72
- await writeFile(tmp, JSON.stringify(parsed, null, 2) + "\n", "utf8");
73
- await rename(tmp, dest);
74
- }
75
- catch (e) {
76
- await unlink(tmp).catch(() => { }); // never leave a half-written tmp behind on a write/rename failure
77
- throw e;
78
- }
71
+ writeFileAtomic(path.join(dir, `${domain}.json`), JSON.stringify(parsed, null, 2) + "\n", "config-document");
79
72
  return parsed;
80
73
  }
81
74
  /** LIST every env-NAME the current local config requires (across models/workers/mcp + the
@@ -1,6 +1,6 @@
1
1
  import { type EffectiveReadWarning } from "./config-fns.js";
2
2
  import type { ConfigReader } from "./reader.js";
3
- import { type DomainConfig, type DomainName, type EffectiveConfig } from "./types.js";
3
+ import { type DomainConfig, type DomainName, type EffectiveConfig, type RetiredDomainName } from "./types.js";
4
4
  /** The domains a single-machine TOC carries: what `FileConfigStore` reads out of `config.d/`, and the
5
5
  * **default**域集 of the bundle helpers (`exportBundle`/`readBundle`). 每个被排除的域必须在
6
6
  * {@link NON_PORTABLE_DOMAIN_REASONS} 里有一条成文理由(禁裸排除,#282 件5 附带裁定)。
@@ -15,7 +15,7 @@ import { type DomainConfig, type DomainName, type EffectiveConfig } from "./type
15
15
  * 同形**。当年 `limits` 域就是这样在单机腿静默丢了一整片限额发布(远端腿同批可用,所以更难看出来)。
16
16
  * 今日的兜底:漏登记的文件会被 {@link FileConfigStore} 的 `"unread-config-file"` 警告点名(fail-loud,
17
17
  * 见 getEffective),但那只在消费方接了 `onWarning` 时才响 —— **登记仍是第一现场的义务**。 */
18
- export declare const PORTABLE_DOMAINS: readonly ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "runtime", "governance", "execution", "prompts", "limits"];
18
+ export declare const PORTABLE_DOMAINS: readonly ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "governance", "execution", "prompts", "limits"];
19
19
  export type PortableDomain = (typeof PORTABLE_DOMAINS)[number];
20
20
  /** `config.d/` 里**合法存在但不是域文档**的文件名(不进 `"unread-config-file"` 告警)。`remote-exec`
21
21
  * 不是 `DOMAIN_SCHEMAS` 成员(resolver 不读它),按 service 表决住在这个目录,由 loadRemoteExec 读
@@ -35,9 +35,9 @@ export declare const NON_DOMAIN_CONFIG_FILES: ReadonlySet<string>;
35
35
  export declare const NON_PORTABLE_DOMAIN_REASONS: {
36
36
  readonly workers: "FLEET-only:单机没有 reconciler,`{workers:[]}` 的 schema 默认就是正确语义(缺席不需要 stub)。";
37
37
  readonly hosts: "FLEET-only:同 workers —— 单机无机器注册表/自动排布,空注册表即正确语义。";
38
- readonly systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 center 侧生命周期,单机 lane 无对应执法点。";
38
+ readonly systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 web-admin 侧生命周期,单机 lane 无对应执法点。";
39
39
  readonly projects: "TENANT IDENTITY DATA,不是可搬运的配置:登记簿随包复制进另一个部署 = 凭空多出的身份登记(design/142 §1.4 威胁模型①「carried-in foreign projectId」换了身 import 的衣服)。要带走走 142 §5 的导出面(service 半场)。";
40
- readonly entitlement: "per-PRINCIPAL 商务/治理数据(tier·预算上限·kill-switch·allowlist),与 projects 同威胁模型(#282 件5 附带裁定,此前是唯一裸排除项):(a) 搬走 = 把一个租户的商务档复制进另一部署;(b) 反向也不成立 —— 单机 TOC 无 principal 轴(无鉴权语境、无 `/effective?principal=` 解析面),本地 entitlement.json 只会造出一份**永不被执法**的假管控面(真执法点在 service/center 边界)。缺席语义如实:本地 buildEffective 给它 schema 默认值(空表),这**不等于**把远端 kill-switch/预算上限搬到了本地 —— 单机 lane 本就没有那个执法点。";
40
+ readonly entitlement: "per-PRINCIPAL 商务/治理数据(tier·预算上限·kill-switch·allowlist),与 projects 同威胁模型(#282 件5 附带裁定,此前是唯一裸排除项):(a) 搬走 = 把一个租户的商务档复制进另一部署;(b) 反向也不成立 —— 单机 TOC 无 principal 轴(无鉴权语境、无 `/effective?principal=` 解析面),本地 entitlement.json 只会造出一份**永不被执法**的假管控面(真执法点在 service / web-admin 边界)。缺席语义如实:本地 buildEffective 给它 schema 默认值(空表),这**不等于**把远端 kill-switch/预算上限搬到了本地 —— 单机 lane 本就没有那个执法点。";
41
41
  };
42
42
  /** One unreadable/corrupt domain FILE surfaced by `getEffective({tolerant:true})`(0.10.12,[897]②)。
43
43
  * `error` 是 #readDomainRaw 的 sanitized 文案(带文件路径语境、绝不回显文件内容)。 */
@@ -62,8 +62,9 @@ export declare class FileConfigStore implements ConfigReader {
62
62
  onWarning?: (w: EffectiveReadWarning) => void;
63
63
  });
64
64
  /** Absolute path to a domain's JSON file. Guards the name (defence in depth — a non-domain string like
65
- * `../../secret` must never be joined into a filesystem path, even if a caller bypasses the type). */
66
- domainPath(domain: DomainName): string;
65
+ * `../../secret` must never be joined into a filesystem path, even if a caller bypasses the type). A retired domain's
66
+ * name is accepted: its file is read for the tombstone. */
67
+ domainPath(domain: DomainName | RetiredDomainName): string;
67
68
  /** Read+parse one domain (validated, defaults filled), or `undefined` if the domain is ABSENT — matching
68
69
  * the {@link ConfigReader} contract (reader.ts: absent domain → undefined) and the server stores'
69
70
  * `has(domain) ? parse : undefined`. A present-but-empty file (`{}` / whitespace) IS a set domain → parsed
@@ -16,6 +16,9 @@
16
16
  * "absent/defaults" the file must be missing, not empty. A valid `{}` object IS a set domain (→ defaults).
17
17
  * - Non-portable domains are OMITTED on a single machine and resolve to their schema defaults (`{workers:[]}`
18
18
  * etc.) — no stubbing needed. 逐域为什么被排除见 {@link NON_PORTABLE_DOMAIN_REASONS}(禁裸排除)。
19
+ * - A RETIRED domain's file (`config.d/runtime.json`, see `RETIRED_DOMAINS`) is read only for its tombstone: carrying facts ⇒
20
+ * every read entry (`getDomain`, `getEffective` — and so `exportBundle`) throws `RetiredDomainError` (`refuseRetiredDomainFiles`, shared with `readBundle`) (the sentence names the one-time migration — `npx @sema-agent/settings-schema migrate
21
+ * <config.d directory>` rewrites the directory); `{}` carries nothing and is left alone. It is never an "unread file".
19
22
  * - The JSON files only ever carry env-NAMEs (the ENV_NAME boundary holds locally too); `.env` holds values.
20
23
  * - `getVersion()` = a stable content hash of all portable files → a u53 number, so a consumer's
21
24
  * "skip re-apply if version unchanged" works locally. `getEffective().updatedAt` = the max file mtime,
@@ -24,8 +27,11 @@
24
27
  import { readdir, readFile, stat } from "node:fs/promises";
25
28
  import path from "node:path";
26
29
  import { buildEffective, DOMAIN_READ_FALLBACK, parseDomainLoud } from "./config-fns.js";
30
+ import { configDPath, readConfigDFile, refuseRetiredDomainFiles } from "./config-d-file.js";
27
31
  import { stableHash } from "./hash.js";
28
- import { isDomainName } from "./types.js";
32
+ import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
33
+ /** The retired domain names (`RETIRED_DOMAINS` keys) — read from `config.d/` for their tombstone only. */
34
+ const RETIRED_DOMAIN_NAMES = Object.keys(RETIRED_DOMAINS);
29
35
  /** The domains a single-machine TOC carries: what `FileConfigStore` reads out of `config.d/`, and the
30
36
  * **default**域集 of the bundle helpers (`exportBundle`/`readBundle`). 每个被排除的域必须在
31
37
  * {@link NON_PORTABLE_DOMAIN_REASONS} 里有一条成文理由(禁裸排除,#282 件5 附带裁定)。
@@ -40,7 +46,7 @@ import { isDomainName } from "./types.js";
40
46
  * 同形**。当年 `limits` 域就是这样在单机腿静默丢了一整片限额发布(远端腿同批可用,所以更难看出来)。
41
47
  * 今日的兜底:漏登记的文件会被 {@link FileConfigStore} 的 `"unread-config-file"` 警告点名(fail-loud,
42
48
  * 见 getEffective),但那只在消费方接了 `onWarning` 时才响 —— **登记仍是第一现场的义务**。 */
43
- export const PORTABLE_DOMAINS = ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "runtime", "governance", "execution", "prompts", "limits"];
49
+ export const PORTABLE_DOMAINS = ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "governance", "execution", "prompts", "limits"];
44
50
  /** `config.d/` 里**合法存在但不是域文档**的文件名(不进 `"unread-config-file"` 告警)。`remote-exec`
45
51
  * 不是 `DOMAIN_SCHEMAS` 成员(resolver 不读它),按 service 表决住在这个目录,由 loadRemoteExec 读
46
52
  * ——**它有自己的读取与校验路径**,所以既不是「未知文件」也不是「域文件」。
@@ -59,9 +65,9 @@ export const NON_DOMAIN_CONFIG_FILES = new Set(["remote-exec.json"]);
59
65
  export const NON_PORTABLE_DOMAIN_REASONS = {
60
66
  workers: "FLEET-only:单机没有 reconciler,`{workers:[]}` 的 schema 默认就是正确语义(缺席不需要 stub)。",
61
67
  hosts: "FLEET-only:同 workers —— 单机无机器注册表/自动排布,空注册表即正确语义。",
62
- systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 center 侧生命周期,单机 lane 无对应执法点。",
68
+ systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 web-admin 侧生命周期,单机 lane 无对应执法点。",
63
69
  projects: "TENANT IDENTITY DATA,不是可搬运的配置:登记簿随包复制进另一个部署 = 凭空多出的身份登记(design/142 §1.4 威胁模型①「carried-in foreign projectId」换了身 import 的衣服)。要带走走 142 §5 的导出面(service 半场)。",
64
- entitlement: "per-PRINCIPAL 商务/治理数据(tier·预算上限·kill-switch·allowlist),与 projects 同威胁模型(#282 件5 附带裁定,此前是唯一裸排除项):(a) 搬走 = 把一个租户的商务档复制进另一部署;(b) 反向也不成立 —— 单机 TOC 无 principal 轴(无鉴权语境、无 `/effective?principal=` 解析面),本地 entitlement.json 只会造出一份**永不被执法**的假管控面(真执法点在 service/center 边界)。缺席语义如实:本地 buildEffective 给它 schema 默认值(空表),这**不等于**把远端 kill-switch/预算上限搬到了本地 —— 单机 lane 本就没有那个执法点。",
70
+ entitlement: "per-PRINCIPAL 商务/治理数据(tier·预算上限·kill-switch·allowlist),与 projects 同威胁模型(#282 件5 附带裁定,此前是唯一裸排除项):(a) 搬走 = 把一个租户的商务档复制进另一部署;(b) 反向也不成立 —— 单机 TOC 无 principal 轴(无鉴权语境、无 `/effective?principal=` 解析面),本地 entitlement.json 只会造出一份**永不被执法**的假管控面(真执法点在 service / web-admin 边界)。缺席语义如实:本地 buildEffective 给它 schema 默认值(空表),这**不等于**把远端 kill-switch/预算上限搬到了本地 —— 单机 lane 本就没有那个执法点。",
65
71
  };
66
72
  export class FileConfigStore {
67
73
  root;
@@ -74,50 +80,20 @@ export class FileConfigStore {
74
80
  this.opts = opts;
75
81
  }
76
82
  /** Absolute path to a domain's JSON file. Guards the name (defence in depth — a non-domain string like
77
- * `../../secret` must never be joined into a filesystem path, even if a caller bypasses the type). */
83
+ * `../../secret` must never be joined into a filesystem path, even if a caller bypasses the type). A retired domain's
84
+ * name is accepted: its file is read for the tombstone. */
78
85
  domainPath(domain) {
79
- if (!isDomainName(domain))
80
- throw new Error(`"${domain}" is not a config domain — refusing to build a config.d path (no traversal)`);
81
- return path.join(this.root, "config.d", `${domain}.json`);
86
+ return configDPath(this.root, domain); // the one name guard (no traversal) lives there
82
87
  }
83
- /** Read + JSON.parse one domain file. A MISSING file (ENOENT) → undefined = the domain is ABSENT (the ONLY
84
- * way to mean "use defaults"). A present file that is EMPTY/whitespace-only, or parses to anything but a JSON
85
- * OBJECT (literal `null`, an array, a number/string/bool), is CORRUPT → throw, FAIL CLOSED — identical
86
- * discipline to loadRemoteExec + the `file:` ref (an empty present file is a half-write/truncation, NOT
87
- * "absent": treating it as absent would silently DROP a published domain — e.g. a truncated runtime.json
88
- * drops its commandPolicy/autonomy guardrails and the consumer falls back to env). This also keeps
89
- * getVersion in lock-step with getEffective/getDomain: stableStringify collapses `undefined` (absent) and
90
- * `null` to the SAME bytes, so an empty/null file that resolved to undefined would hash IDENTICALLY to
91
- * "domain absent" and a version-gated poller would skip a change getEffective/getDomain actually FAIL on.
92
- * Every domain schema is a z.object, so a non-object/empty file is never valid — reject all those shapes
93
- * here, once. R17 + R(0.1.27 整体复审). */
88
+ /** Read + JSON.parse one domain file —— the ONE `config.d/` file reading (`./config-d-file`, shared with `readBundle`): a MISSING
89
+ * file (ENOENT) → undefined = the domain is ABSENT (the ONLY way to mean "use defaults"); a present file that is EMPTY/whitespace-only,
90
+ * or parses to anything but a JSON OBJECT, is CORRUPT → throw, FAIL CLOSED (an empty present file is a half-write/truncation, NOT
91
+ * "absent": treating it as absent would silently DROP a published domain — e.g. a truncated governance.json drops its
92
+ * commandPolicy/autonomy guardrails). This also keeps getVersion in lock-step with getEffective/getDomain: stableStringify collapses
93
+ * `undefined` (absent) and `null` to the SAME bytes, so an empty/null file that resolved to undefined would hash IDENTICALLY to
94
+ * "domain absent" and a version-gated poller would skip a change getEffective/getDomain actually FAIL on. R17 + R(0.1.27 整体复审). */
94
95
  async #readDomainRaw(domain) {
95
- let text;
96
- try {
97
- text = await readFile(this.domainPath(domain), "utf8");
98
- }
99
- catch (e) {
100
- if (e.code === "ENOENT")
101
- return undefined;
102
- throw e;
103
- }
104
- const trimmed = text.trim();
105
- // FAIL CLOSED on an empty/whitespace present file (half-write/truncation), same as loadRemoteExec/file: —
106
- // to mean "absent → defaults" the file must be MISSING (ENOENT), never present-but-empty.
107
- if (!trimmed)
108
- throw new Error(`config.d/${domain}.json is empty/whitespace — delete it for the domain default, or write a valid object`);
109
- let parsed;
110
- try {
111
- parsed = JSON.parse(trimmed);
112
- }
113
- catch {
114
- throw new Error(`invalid JSON in config.d/${domain}.json`); // sanitized: never echo the raw file text into the error
115
- }
116
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
117
- const kind = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
118
- throw new Error(`config.d/${domain}.json must be a JSON object (got ${kind}) — delete the file for the domain default, or write a valid object`);
119
- }
120
- return parsed;
96
+ return readConfigDFile(this.root, domain); // the shared reader guards the name (configDPath)
121
97
  }
122
98
  /**
123
99
  * FAIL-LOUD 扫描(0.19.0):`config.d/` 里存在、但本地读取集**永远不会读**的 `*.json` 文件,逐个经
@@ -152,15 +128,19 @@ export class FileConfigStore {
152
128
  const base = name.slice(0, -".json".length);
153
129
  if (PORTABLE_DOMAINS.includes(base))
154
130
  continue; // 真被读的域
131
+ if (isRetiredDomainName(base))
132
+ continue; // retired 域:由墓碑判(带事实 ⇒ getEffective 抛),不是「没人读的文件」
155
133
  onWarning(isDomainName(base)
156
134
  ? { kind: "unread-config-file", file: name, domain: base, why: "non-portable-domain" }
157
135
  : { kind: "unread-config-file", file: name, why: "not-a-domain" });
158
136
  }
159
137
  }
160
- /** Raw (unparsed) values for every PORTABLE domain, keyed by domain name (absent → undefined). */
138
+ /** Raw (unparsed) values for every PORTABLE domain and every retired domain (the latter for the tombstone), keyed by
139
+ * name (absent → undefined). Both feed the version hash: a retired file appearing / changing flips `getEffective`
140
+ * between a refusal and a read, so a version-gated poller must see it. */
161
141
  async #readAllRaw() {
162
142
  const out = {};
163
- await Promise.all(PORTABLE_DOMAINS.map(async (d) => { out[d] = await this.#readDomainRaw(d); }));
143
+ await Promise.all([...PORTABLE_DOMAINS, ...RETIRED_DOMAIN_NAMES].map(async (d) => { out[d] = await this.#readDomainRaw(d); }));
164
144
  return out;
165
145
  }
166
146
  /** The max mtime (ISO) across the present portable files, or the epoch when none exist — so `updatedAt`
@@ -186,6 +166,7 @@ export class FileConfigStore {
186
166
  * with defaults; a missing file is absence → undefined. (getEffective still fills defaults for absent
187
167
  * domains; getDomain is the per-domain "was this ever set?" probe.) */
188
168
  async getDomain(domain) {
169
+ await refuseRetiredDomainFiles(this.root); // every read entry asks the tombstone first (exportBundle reads through here)
189
170
  const raw = await this.#readDomainRaw(domain);
190
171
  if (raw === undefined)
191
172
  return undefined; // missing/empty file → domain absent (not schema defaults)
@@ -199,14 +180,20 @@ export class FileConfigStore {
199
180
  return parseInt(stableHash(raws).slice(0, 13), 16); // 13 hex digits ≤ 2^52, safely within u53
200
181
  }
201
182
  async getEffective(opts) {
183
+ // Every read entry asks the tombstone first (the same check getDomain runs); what it read (absent / `{}`) joins the version hash.
202
184
  if (opts?.tolerant !== true) {
203
- const [raws] = await Promise.all([this.#readAllRaw(), this.#warnUnreadFiles()]); // 两条读法同一门(0.19.0)
185
+ const [retired] = await Promise.all([refuseRetiredDomainFiles(this.root), this.#warnUnreadFiles()]); // 两条读法同一门(0.19.0)
186
+ const raws = { ...retired };
187
+ await Promise.all(PORTABLE_DOMAINS.map(async (d) => { raws[d] = await this.#readDomainRaw(d); }));
204
188
  const updatedAt = await this.#maxMtimeIso();
205
189
  return buildEffective(this.#versionOf(raws), (d) => raws[d], updatedAt, this.opts.onWarning);
206
190
  }
207
191
  await this.#warnUnreadFiles();
208
- const raws = {};
209
- const hashRaws = {};
192
+ // A retired domain's file is read strictly (no tolerance): it exists only to be refused, and an unreadable one is still
193
+ // an unmigrated document sitting in the directory.
194
+ const retired = await refuseRetiredDomainFiles(this.root);
195
+ const raws = { ...retired };
196
+ const hashRaws = { ...retired };
210
197
  const domainErrors = [];
211
198
  await Promise.all(PORTABLE_DOMAINS.map(async (d) => {
212
199
  try {