@sema-agent/settings-schema 17.1.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +73 -5
  3. package/conformance/VERSION +1 -1
  4. package/conformance/baseline.json +3 -0
  5. package/conformance/cases/c01-entry-salvage-then-typo/expected.json +4 -1
  6. package/conformance/cases/c02-hosts-clamp-stacked/expected.json +4 -1
  7. package/conformance/cases/c03-literal-dotted-top-key/expected.json +4 -1
  8. package/conformance/cases/c04-all-bad-collection-not-salvaged/expected.json +4 -1
  9. package/conformance/cases/c07-hooks-is-not-a-config-domain/expected.json +4 -1
  10. package/conformance/cases/c08-limits-unknown-carried-64/expected.json +4 -1
  11. package/conformance/cases/c09-limits-unknown-carried-65/expected.json +4 -1
  12. package/conformance/cases/c10-record-key-secret-redacted-twice/expected.json +4 -1
  13. package/conformance/cases/c11-unicode-unknown-keys/expected.json +4 -1
  14. package/conformance/cases/c12-prototype-name-top-keys/expected.json +4 -1
  15. package/conformance/cases/c13-models-field-null/expected.json +4 -1
  16. package/conformance/cases/c14-models-field-empty-array/expected.json +4 -1
  17. package/conformance/cases/c15-models-field-number/expected.json +4 -1
  18. package/conformance/cases/c16-deep-nesting-205/expected.json +3 -0
  19. package/conformance/cases/c17-empty-domain-file/expected.json +3 -0
  20. package/conformance/cases/c18-wire-envelope-not-an-object/expected.json +4 -1
  21. package/conformance/cases/c19-wire-envelope-no-version/expected.json +4 -1
  22. package/conformance/cases/c21-governance-empty-object-wins/expected.json +4 -1
  23. package/conformance/cases/c23-mixed-validity-models/expected.json +4 -1
  24. package/conformance/cases/c24-valid-entries-with-unknown-keys/expected.json +4 -1
  25. package/conformance/cases/c25-published-roundtrip-zero-warnings/expected.json +4 -1
  26. package/conformance/cases/c26-hosts-array-1024/expected.json +4 -1
  27. package/conformance/cases/c27-hosts-array-1025/expected.json +4 -1
  28. package/conformance/cases/c28-large-document-over-1mb/expected.json +4 -1
  29. package/conformance/cases/c29-salvage-then-domain-refine-fails/expected.json +4 -1
  30. package/conformance/cases/c30-version-triple-a-first/expected.json +4 -1
  31. package/conformance/cases/c31-version-triple-b-changed/expected.json +4 -1
  32. package/conformance/cases/c32-version-triple-a-restored/expected.json +4 -1
  33. package/conformance/cases/c33-limits-nested-carried-key/expected.json +4 -1
  34. package/conformance/cases/c34-skills-content-vs-manifest/expected.json +7 -1
  35. package/conformance/cases/c35-portable-domain-file-is-null/expected.json +3 -0
  36. package/conformance/cases/c36-skills-entry-without-content/expected.json +7 -1
  37. package/conformance/cases/c37-skills-field-null/expected.json +6 -0
  38. package/conformance/cases/c38-remote-exec-is-a-non-domain-file/expected.json +4 -1
  39. package/conformance/cases/c39-hidden-file-in-config-d/expected.json +4 -1
  40. package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +4 -1
  41. package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +4 -1
  42. package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +4 -1
  43. package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +4 -1
  44. package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +4 -1
  45. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +4 -1
  46. package/conformance/cases/c46-s811-host-name-url-userinfo-withheld/expected.json +4 -1
  47. package/conformance/cases/c47-s811-roles-key-url-userinfo-withheld/expected.json +4 -1
  48. package/conformance/cases/c48-s935-model-headers-transported/expected.json +4 -1
  49. package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/expected.json +4 -1
  50. package/conformance/cases/c50-s784-lane-host-legal/expected.json +4 -1
  51. package/conformance/cases/c51-s784-lane-e2b-legal/expected.json +4 -1
  52. package/conformance/cases/c52-s784-lane-k8s-legal/expected.json +4 -1
  53. package/conformance/cases/c53-s784-lane-ssh-legal/expected.json +4 -1
  54. package/conformance/cases/c54-s784-lane-adb-legal/expected.json +4 -1
  55. package/conformance/cases/c55-s784-lane-local-docker-legal/expected.json +4 -1
  56. package/conformance/cases/c56-s784-lane-device-legal/expected.json +4 -1
  57. package/conformance/cases/c57-s784-g1-enabled-no-lane/expected.json +4 -1
  58. package/conformance/cases/c58-s784-g2-multitenant-host/expected.json +4 -1
  59. package/conformance/cases/c59-s784-g3-k8s-missing-image/expected.json +4 -1
  60. package/conformance/cases/c60-s784-g3-e2b-missing-key/expected.json +4 -1
  61. package/conformance/cases/c61-s784-g3-other-lane-key/expected.json +4 -1
  62. package/conformance/cases/c62-s784-g7a-session-tidb/expected.json +4 -1
  63. package/conformance/cases/c63-s784-host-two-replicas/expected.json +4 -1
  64. package/conformance/cases/c64-s784-multitenant-local-store/expected.json +4 -1
  65. package/conformance/cases/c65-s784-pg-without-db/expected.json +4 -1
  66. package/conformance/cases/c66-s784-memory-backend-mismatch/expected.json +4 -1
  67. package/conformance/cases/c67-s784-retired-field-loud/expected.json +4 -1
  68. package/conformance/cases/c68-s784-device-local-store/expected.json +4 -1
  69. package/conformance/cases/c69-deep-encoded-token-past-every-reading/expected.json +4 -1
  70. package/conformance/cases/c70-rewritten-token-in-front-of-encoded-pem-header/expected.json +4 -1
  71. package/conformance/cases/c71-one-scanner-write-reads-display-forms/expected.json +4 -1
  72. package/conformance/cases/c72-dangling-reference-write-seat-refuses/expected.json +4 -1
  73. package/conformance/cases/c73-projects-extended-registration-reads/expected.json +4 -1
  74. package/conformance/cases/c74-projects-over-bound-refused/expected.json +4 -1
  75. package/conformance/cases/c75-projects-patch-remote-uniqueness/expected.json +4 -1
  76. package/conformance/cases/c76-projects-entry-salvage-nested/expected.json +4 -1
  77. package/conformance/cases/c77-prompts-center-artifact-carried/expected.json +4 -1
  78. package/conformance/cases/c78-prompts-empty-bindings-refused/expected.json +4 -1
  79. package/conformance/cases/c79-prompts-bad-worker-artifact-not-salvaged/expected.json +4 -1
  80. package/conformance/cases/c80-prompts-wire-v2-verbatim/expected.json +7 -1
  81. package/conformance/cases/c81-prompts-declaration-bindings-refused/expected.json +4 -1
  82. package/conformance/cases/c82-prompts-v1-catalog-refused/expected.json +4 -1
  83. package/conformance/cases/c83-prompts-bad-overlay-not-salvaged/expected.json +4 -1
  84. package/conformance/cases/c84-sandbox-git-block-carried-verbatim/expected.json +7 -1
  85. package/conformance/cases/c85-sandbox-git-refused-block-still-carried/expected.json +7 -1
  86. package/conformance/cases/c86-a2a-peers-legal/expected.json +135 -0
  87. package/conformance/cases/c86-a2a-peers-legal/input.json +32 -0
  88. package/conformance/cases/c86-a2a-peers-legal/meta.json +5 -0
  89. package/conformance/cases/c87-a2a-credential-refs-not-partially-salvaged/expected.json +123 -0
  90. package/conformance/cases/c87-a2a-credential-refs-not-partially-salvaged/input.json +39 -0
  91. package/conformance/cases/c87-a2a-credential-refs-not-partially-salvaged/meta.json +5 -0
  92. package/conformance/cases/cc01-mcp-toolfaces-non-object/expected.json +4 -1
  93. package/conformance/cases/cc02-mcp-source-key-is-transported/expected.json +4 -1
  94. package/conformance/cases/cc03-model-compat-unknown-subkey/expected.json +4 -1
  95. package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +4 -1
  96. package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +4 -1
  97. package/conformance/cases/cli03-credential-pasted-into-unknown-key/expected.json +4 -1
  98. package/conformance/cases/cli04-env-value-with-newline/expected.json +4 -1
  99. package/conformance/cases/cli05-long-and-prefix-colliding-names/expected.json +4 -1
  100. package/conformance/cases/t01-absent-domain-key-not-null/expected.json +4 -1
  101. package/conformance/cases/t03-prototype-name-key-not-swallowed/expected.json +4 -1
  102. package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +4 -1
  103. package/conformance/cases/z01-every-domain-empty-object/expected.json +4 -1
  104. package/conformance/cases/z02-every-domain-unknown-key/expected.json +4 -1
  105. package/dist/atomic-write.d.ts +4 -3
  106. package/dist/atomic-write.js +25 -11
  107. package/dist/catalog-manages.d.ts +8 -3
  108. package/dist/catalog-manages.js +16 -7
  109. package/dist/center-refs.d.ts +4 -3
  110. package/dist/center-refs.js +8 -6
  111. package/dist/cli.d.ts +1 -1
  112. package/dist/config-d-file.d.ts +4 -2
  113. package/dist/config-d-file.js +27 -6
  114. package/dist/config-fns.d.ts +6 -1
  115. package/dist/config-fns.js +16 -2
  116. package/dist/config-root-documents.d.ts +2 -2
  117. package/dist/config-root-documents.js +1 -1
  118. package/dist/config-root-node.d.ts +8 -1
  119. package/dist/config-root-node.js +105 -23
  120. package/dist/env-model-table.js +3 -3
  121. package/dist/execution-lanes.d.ts +158 -11
  122. package/dist/execution-lanes.js +76 -11
  123. package/dist/file-edit.d.ts +1 -1
  124. package/dist/file-edit.js +2 -1
  125. package/dist/index.d.ts +1 -1
  126. package/dist/index.js +1 -1
  127. package/dist/mcp-request-entry.d.ts +11 -21
  128. package/dist/mcp-request-entry.js +21 -38
  129. package/dist/node.d.ts +4 -1
  130. package/dist/node.js +6 -2
  131. package/dist/one-time-migrations.js +1 -1
  132. package/dist/portable-domains.d.ts +1 -1
  133. package/dist/portable-domains.js +1 -1
  134. package/dist/remote-exec.d.ts +5 -0
  135. package/dist/remote-exec.js +5 -0
  136. package/dist/reserved-member-name.d.ts +13 -0
  137. package/dist/reserved-member-name.js +14 -0
  138. package/dist/retired-envs.js +2 -0
  139. package/dist/secret-refs.d.ts +1 -1
  140. package/dist/secret-refs.js +5 -1
  141. package/dist/settings-units.d.ts +3 -0
  142. package/dist/settings-units.js +18 -0
  143. package/dist/types.d.ts +70 -41
  144. package/dist/types.js +179 -32
  145. package/dist/url-credential.d.ts +12 -2
  146. package/dist/url-credential.js +164 -9
  147. package/dist/wire-name.d.ts +19 -0
  148. package/dist/wire-name.js +21 -0
  149. package/docs/API-SURFACE.md +31 -6
  150. package/docs/CC-DIVERGENCE.md +6 -0
  151. package/docs/CONSUMER-INTEGRATION.md +127 -7
  152. package/llms-full.txt +40 -17
  153. package/llms.txt +9 -9
  154. package/package.json +1 -1
@@ -4,10 +4,15 @@
4
4
  * 包内私有(不进包根):这是「`enabled` 怎么读」的**唯一实现**;条目的形由调用方判一次(被丢条目身份在读 `name` / `id` 之前已判过,这里不再问第二次)。
5
5
  */
6
6
  export declare function ownEnabledFlag(entry: object): boolean | undefined;
7
- /** 一条目录条目**原文**的 `enabled`:先判形(不是纯 JSON 对象 ⇒ `undefined`),再读 {@link ownEnabledFlag}。「显式停用」与计划助手的启用过滤都调它。 */
8
- export declare function entryEnabledFlag(entry: unknown): boolean | undefined;
9
7
  /**
10
- * 文档里**不是显式停用**的条目数(日志计数用;判官 {@link catalogManagesModelPlane} 就是它 > 0)。入参是 models 文档原文或读过的 `ModelsConfig`(两者同形)。
8
+ * 一条目录条目**原文**是不是「显式停用」—— **根桶出口**,「显式停用」这条律(纯 JSON 对象、自有数据属性 `enabled` 恰为 `false`)全包只在这里写一次。
9
+ * 入参是解析出来的 JSON(models 文档里的一项);**不是**纯 JSON 对象的值(串 / 数 / `null` / 数组 / 类实例 / `Map` / 函数 / 撤销的 Proxy)、`enabled` 缺席、只靠继承、是访问器(不调)、不是布尔 ⇒ `false`
10
+ * (不是显式停用 ⇒ 在管文档里计入,与判官同答)。读 `enabled` 的反射本身抛(Proxy 的描述符陷阱,JSON 解析不出这种值)⇒ 照抛,不吞成 `false`({@link ownEnabledFlag} 的既有臂);要整份文档的口径(读不出 ⇒ `TypeError`)
11
+ * 问 {@link enabledEntryCount} / {@link catalogManagesModelPlane}。纯、浏览器安全。
12
+ */
13
+ export declare function isExplicitlyDisabledEntry(entry: unknown): boolean;
14
+ /**
15
+ * 文档里**不是显式停用**({@link isExplicitlyDisabledEntry})的条目数(日志计数用;判官 {@link catalogManagesModelPlane} 就是它 > 0)。入参是 models 文档原文或读过的 `ModelsConfig`(两者同形)。
11
16
  * 读不出 ⇒ `TypeError`(见文件头)。
12
17
  */
13
18
  export declare function enabledEntryCount(doc: unknown): number;
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * ## 律(一条)
5
5
  * **文档的 `models` 里至少一条不是「显式停用」的条目 ⇔ 管。** 「显式停用」= 纯 JSON 对象、自有数据属性 `enabled` 恰为 `false`
6
- * ({@link ownEnabledFlag} 是这一读法的**唯一**实现:被丢条目身份的 `enabled`(`./config-fns` 的 `readEntryIdentity`)读的是同一只;{@link entryEnabledFlag} 只是它前面判一次形的入口)。
6
+ * ({@link ownEnabledFlag} 是这一读法的**唯一**实现:被丢条目身份的 `enabled`(`./config-fns` 的 `readEntryIdentity`)读的是同一只;{@link entryEnabledFlag} 是它前面判一次形的入口,**模块私有**;
7
+ * 「恰为 `false`」这一句只在 {@link isExplicitlyDisabledEntry} 写一次,它是出口 —— 判官、计数、`./env-model-table` 的启用过滤与每个要问「这一条停用了吗」的消费者都读它,不各写一份 `=== false`)。
7
8
  * 判官 = 计数 > 0:「管不管」与「几条在管」是同一个事实的两种问法,只有一种读法。
8
9
  *
9
10
  * · 文档不是纯对象、`models` 缺席或不是数组 ⇒ 0 条(不管);数组里的洞不是条目;
@@ -25,14 +26,22 @@ export function ownEnabledFlag(entry) {
25
26
  const value = d !== undefined && !isAccessorDescriptor(d) ? d.value : undefined; // 访问器不调(描述符的字段只认自有的,见内核)
26
27
  return typeof value === "boolean" ? value : undefined;
27
28
  }
28
- /** 一条目录条目**原文**的 `enabled`:先判形(不是纯 JSON 对象 ⇒ `undefined`),再读 {@link ownEnabledFlag}。「显式停用」与计划助手的启用过滤都调它。 */
29
- export function entryEnabledFlag(entry) {
29
+ /** 一条目录条目**原文**的 `enabled`:先判形(不是纯 JSON 对象 ⇒ `undefined`),再读 {@link ownEnabledFlag}。**模块私有** —— 三态读数不出门:出三态就是把「显式停用 ⇔ flag 恰为 false」这条律交给每个消费者各复述一遍,
30
+ * 消费者问的是 {@link isExplicitlyDisabledEntry}。 */
31
+ function entryEnabledFlag(entry) {
30
32
  return isPlainJsonObject(entry) ? ownEnabledFlag(entry) : undefined;
31
33
  }
32
- /** 「显式停用」:{@link entryEnabledFlag} 恰为 `false`。 */
33
- const isExplicitlyDisabled = (entry) => entryEnabledFlag(entry) === false;
34
34
  /**
35
- * 文档里**不是显式停用**的条目数(日志计数用;判官 {@link catalogManagesModelPlane} 就是它 > 0)。入参是 models 文档原文或读过的 `ModelsConfig`(两者同形)。
35
+ * 一条目录条目**原文**是不是「显式停用」—— **根桶出口**,「显式停用」这条律(纯 JSON 对象、自有数据属性 `enabled` 恰为 `false`)全包只在这里写一次。
36
+ * 入参是解析出来的 JSON(models 文档里的一项);**不是**纯 JSON 对象的值(串 / 数 / `null` / 数组 / 类实例 / `Map` / 函数 / 撤销的 Proxy)、`enabled` 缺席、只靠继承、是访问器(不调)、不是布尔 ⇒ `false`
37
+ * (不是显式停用 ⇒ 在管文档里计入,与判官同答)。读 `enabled` 的反射本身抛(Proxy 的描述符陷阱,JSON 解析不出这种值)⇒ 照抛,不吞成 `false`({@link ownEnabledFlag} 的既有臂);要整份文档的口径(读不出 ⇒ `TypeError`)
38
+ * 问 {@link enabledEntryCount} / {@link catalogManagesModelPlane}。纯、浏览器安全。
39
+ */
40
+ export function isExplicitlyDisabledEntry(entry) {
41
+ return entryEnabledFlag(entry) === false;
42
+ }
43
+ /**
44
+ * 文档里**不是显式停用**({@link isExplicitlyDisabledEntry})的条目数(日志计数用;判官 {@link catalogManagesModelPlane} 就是它 > 0)。入参是 models 文档原文或读过的 `ModelsConfig`(两者同形)。
36
45
  * 读不出 ⇒ `TypeError`(见文件头)。
37
46
  */
38
47
  export function enabledEntryCount(doc) {
@@ -49,7 +58,7 @@ export function enabledEntryCount(doc) {
49
58
  for (let i = 0; i < models.length; i++) {
50
59
  if (!Object.hasOwn(models, i))
51
60
  continue; // 洞不是条目:只认自有下标(`forEach` / `for…of` 会读到被污染的 `Array.prototype[i]`)
52
- if (!isExplicitlyDisabled(models[i]))
61
+ if (!isExplicitlyDisabledEntry(models[i]))
53
62
  count++;
54
63
  }
55
64
  return count;
@@ -8,8 +8,9 @@
8
8
  * ## 律(一条)
9
9
  * 引用表怎么读、每个变量怎么问、读不出怎么说,只有这一处:
10
10
  * · **引用表经公开的记录读器读**({@link readPlainStringRecord}:同一个函数、同一张原因词表 {@link PLAIN_STRING_RECORD_FAULTS}),不另写形状 / 描述符判据。读不出(整张不是纯数据、有成员的变量名不是串、
11
- * 读成员就抛、叫 `__proto__` 的成员)⇒ 整台结论 `refs_unreadable`,带读器的词,**一个变量都不问** —— 引用表本身不可信时,问了的值也没有可以挂的地方。
12
- * 读器会把叫 `__proto__` 的串值成员**剔出副本**交其余;本口不许这样悄悄丢一条引用再挂一台少了鉴权的 server,所以同样答 `refs_unreadable`、词 `member_name_reserved`。
11
+ * 读成员就抛、叫保留名 `__proto__` / `constructor` / `prototype` 的成员)⇒ 整台结论 `refs_unreadable`,带读器的词,**一个变量都不问** —— 引用表本身不可信时,问了的值也没有可以挂的地方。
12
+ * 读器会把叫保留名的串值成员**剔出副本**交其余;本口不许这样悄悄丢一条引用再挂一台少了鉴权的 server,所以同样答 `refs_unreadable`、词 `member_name_reserved`,点名第一个(自有键序)。
13
+ * 保留名只有一张表(`./reserved-member-name`):域 schema 的凭据引用集合拒同一组名字,两个读口同答。
13
14
  * · **查找函数 `lookup(name)` 由调用方给**,本口**不读 `process.env`**(源里没有 `process` / `globalThis`;宿主怎么读环境、读哪一层环境、要不要缓存,都是调用方的事)。没给 / 不是函数 ⇒ `TypeError`
14
15
  * (这是调用方的编程错,不是数据错:不折成成员的 `read_threw`,更不回落任何环境)。
15
16
  * · 每个成员恰问一次 `lookup`(按引用表副本的自有键序 —— 整数形的键在前、其余按记录列出的次序;实参只有变量名一个),判成**一个词**({@link CenterRefMemberVerdict}):
@@ -23,7 +24,7 @@
23
24
  *
24
25
  * ## 读数的构造
25
26
  * 成员表用 `map`,`values` 用 `Object.defineProperty` + 零原型描述符({@link dataDescriptor})逐键写进一个新对象(定义语义,不走 [[Set]]):`Object.prototype` 上挂了数字键 setter 时目标键 `"0"` 的值也不会被吞;
26
- * `constructor` / `toString` 这类原型链上的名字作目标键照落成自有数据属性。交出的 `values` 原型是 `Object.prototype`,与读器交出的副本同形。
27
+ * `toString` / `hasOwnProperty` 这类不在保留名表里的原型链名字作目标键照落成自有数据属性。交出的 `values` 原型是 `Object.prototype`,与读器交出的副本同形。
27
28
  */
28
29
  import { type PlainStringRecordFault } from "./mcp-request-entry.js";
29
30
  /** 一个成员没解到的**原因词**,闭集,三个:`missing_name`(查找函数答 `undefined` —— 变量没设)· `not_a_string`(答了别的种类)· `read_threw`(查找函数抛了)。消费方按词分派,穷尽 `switch`,不读句子(本口没有句子)。 */
@@ -8,8 +8,9 @@
8
8
  * ## 律(一条)
9
9
  * 引用表怎么读、每个变量怎么问、读不出怎么说,只有这一处:
10
10
  * · **引用表经公开的记录读器读**({@link readPlainStringRecord}:同一个函数、同一张原因词表 {@link PLAIN_STRING_RECORD_FAULTS}),不另写形状 / 描述符判据。读不出(整张不是纯数据、有成员的变量名不是串、
11
- * 读成员就抛、叫 `__proto__` 的成员)⇒ 整台结论 `refs_unreadable`,带读器的词,**一个变量都不问** —— 引用表本身不可信时,问了的值也没有可以挂的地方。
12
- * 读器会把叫 `__proto__` 的串值成员**剔出副本**交其余;本口不许这样悄悄丢一条引用再挂一台少了鉴权的 server,所以同样答 `refs_unreadable`、词 `member_name_reserved`。
11
+ * 读成员就抛、叫保留名 `__proto__` / `constructor` / `prototype` 的成员)⇒ 整台结论 `refs_unreadable`,带读器的词,**一个变量都不问** —— 引用表本身不可信时,问了的值也没有可以挂的地方。
12
+ * 读器会把叫保留名的串值成员**剔出副本**交其余;本口不许这样悄悄丢一条引用再挂一台少了鉴权的 server,所以同样答 `refs_unreadable`、词 `member_name_reserved`,点名第一个(自有键序)。
13
+ * 保留名只有一张表(`./reserved-member-name`):域 schema 的凭据引用集合拒同一组名字,两个读口同答。
13
14
  * · **查找函数 `lookup(name)` 由调用方给**,本口**不读 `process.env`**(源里没有 `process` / `globalThis`;宿主怎么读环境、读哪一层环境、要不要缓存,都是调用方的事)。没给 / 不是函数 ⇒ `TypeError`
14
15
  * (这是调用方的编程错,不是数据错:不折成成员的 `read_threw`,更不回落任何环境)。
15
16
  * · 每个成员恰问一次 `lookup`(按引用表副本的自有键序 —— 整数形的键在前、其余按记录列出的次序;实参只有变量名一个),判成**一个词**({@link CenterRefMemberVerdict}):
@@ -23,9 +24,9 @@
23
24
  *
24
25
  * ## 读数的构造
25
26
  * 成员表用 `map`,`values` 用 `Object.defineProperty` + 零原型描述符({@link dataDescriptor})逐键写进一个新对象(定义语义,不走 [[Set]]):`Object.prototype` 上挂了数字键 setter 时目标键 `"0"` 的值也不会被吞;
26
- * `constructor` / `toString` 这类原型链上的名字作目标键照落成自有数据属性。交出的 `values` 原型是 `Object.prototype`,与读器交出的副本同形。
27
+ * `toString` / `hasOwnProperty` 这类不在保留名表里的原型链名字作目标键照落成自有数据属性。交出的 `values` 原型是 `Object.prototype`,与读器交出的副本同形。
27
28
  */
28
- import { PLAIN_STRING_RECORD_RESERVED_MEMBER, readPlainStringRecord } from "./mcp-request-entry.js";
29
+ import { readPlainStringRecord } from "./mcp-request-entry.js";
29
30
  import { dataDescriptor, inspect } from "./own-key-read.js";
30
31
  /** 一个成员没解到的**原因词**,闭集,三个:`missing_name`(查找函数答 `undefined` —— 变量没设)· `not_a_string`(答了别的种类)· `read_threw`(查找函数抛了)。消费方按词分派,穷尽 `switch`,不读句子(本口没有句子)。 */
31
32
  export const CENTER_REF_FAULTS = ["missing_name", "not_a_string", "read_threw"];
@@ -55,8 +56,9 @@ export function resolveCenterRefs(refs, lookup) {
55
56
  const read = readPlainStringRecord(refs);
56
57
  if (!read.ok)
57
58
  return { verdict: "refs_unreadable", fault: read.fault, ...(read.member !== undefined ? { member: read.member } : {}) };
58
- if (read.reservedMember)
59
- return { verdict: "refs_unreadable", fault: "member_name_reserved", member: PLAIN_STRING_RECORD_RESERVED_MEMBER };
59
+ const [reserved] = read.reservedMembers;
60
+ if (reserved !== undefined)
61
+ return { verdict: "refs_unreadable", fault: "member_name_reserved", member: reserved };
60
62
  const judged = Object.entries(read.record).map((pair) => ({ key: pair[0], ref: pair[1], answer: answerOf(pair[1], lookup) }));
61
63
  const members = judged.map((j) => ({ key: j.key, ref: j.ref, verdict: j.answer.verdict }));
62
64
  if (judged.some((j) => j.answer.verdict !== "resolved"))
package/dist/cli.d.ts CHANGED
@@ -35,5 +35,5 @@ export interface SettingsSchemaCliIo extends ConfigDirectoryIo {
35
35
  stderr(text: string): void;
36
36
  }
37
37
  /** 命令行的覆盖面:全部配置域、retired 域(便携与否不论 —— 离线的 `workers.json` 一样迁)与 `remote-exec.json`。 */
38
- export declare const COVERED_DOCUMENTS: ("remote-exec" | "models" | "rosters" | "scenarios" | "skills" | "execution" | "plugins" | "systems" | "limits" | "runtime" | "workers" | "hosts" | "projects" | "mcp" | "collab" | "governance" | "entitlement" | "prompts")[];
38
+ export declare const COVERED_DOCUMENTS: ("remote-exec" | "models" | "rosters" | "scenarios" | "skills" | "execution" | "plugins" | "systems" | "limits" | "runtime" | "workers" | "hosts" | "projects" | "mcp" | "collab" | "governance" | "entitlement" | "prompts" | "a2a")[];
39
39
  export declare function runSettingsSchemaCli(argv: readonly string[], io: SettingsSchemaCliIo): Promise<number>;
@@ -41,9 +41,11 @@ export declare function judgeConfigDText(name: ConfigDDocumentName, text: string
41
41
  * 非朴素对象、嵌得过深 ⇒ 抛。{@link judgeConfigDText} 的最后一道判就是这只(文本解析出来之后过它),所以「不是对象」「过深」各一种坏形一句话,不论文档是从文件文本来的还是调用方直接给的;
42
42
  * `label` = 句首的称呼(文本路径给文件的真实路径,值路径给 `the <名> document`)。 */
43
43
  export declare function judgeConfigDValue(name: ConfigDDocumentName, value: unknown, label: string): Record<string, unknown>;
44
- /** 读一个 `config.d/<名>.json` → 读数:路径上没有任何条目 ⇒ 缺席;在场 ⇒ {@link judgeConfigDTextReading};在场却读不出 ⇒ 被拒,词 `io` —— 非 ENOENT 的 fs 错误,以及悬空的符号链接
45
- * (文档本身或 `config.d` 目录:内核跟链接读到 ENOENT,但条目在场;句子是 `danglingSymlinkSentence`(`./atomic-write`)那一句,与目录迁移的读口同一句)。缺席与悬空的分辨在 `readTextIfPresent`。 */
44
+ /** 读一个 `config.d/<名>.json` → 读数:路径上没有任何条目 ⇒ 缺席;在场 ⇒ {@link judgeConfigDTextReading};在场却读不出 ⇒ 被拒,词 `io`({@link refusedIo})。缺席与悬空的分辨在 `readTextIfPresent` 的缺席尾。 */
46
45
  export declare function readConfigDFileReading(root: string, name: ConfigDDocumentName): Promise<ConfigDReading>;
46
+ /** {@link readConfigDFileReading} 的同步孪生(`./node` 出口;壳在同步具名锁里读 `config.d` 文档要它):**同一只纯判**({@link readingOfText} → {@link judgeConfigDTextReading})、**同一个缺席尾**
47
+ * (`readTextIfPresentSync` 与异步读者共用 `absentUnlessDangling`),只有读文本那一行 I/O 不同。名字不是 `config.d` 里能放的文档名 ⇒ 同步抛(与异步口的拒绝同一个错)。 */
48
+ export declare function readConfigDFileReadingSync(root: string, name: ConfigDDocumentName): ConfigDReading;
47
49
  /** 抛出口:读一个 `config.d/<名>.json`:路径上没有任何条目 ⇒ `undefined`;在场 ⇒ 它的值;被拒 ⇒ 抛读数的 `thrown`(见 {@link readConfigDFileReading})。 */
48
50
  export declare function readConfigDFile(root: string, name: ConfigDDocumentName): Promise<Record<string, unknown> | undefined>;
49
51
  /** 一棵 `config.d` 的原文快照:便携域与 retired 域的文档原文(缺席 ⇒ `undefined`),版本哈希的入参。 */
@@ -16,7 +16,7 @@
16
16
  * 句尾出路按文档类({@link documentWayOut}),从不回显文件原文。
17
17
  */
18
18
  import path from "node:path";
19
- import { readTextIfPresent } from "./atomic-write.js";
19
+ import { readTextIfPresent, readTextIfPresentSync } from "./atomic-write.js";
20
20
  import { refuseRetiredDomain, sanitizeZodError } from "./config-fns.js";
21
21
  import { MAX_HASH_DEPTH, stableHash } from "./hash.js";
22
22
  import { UNINSPECTABLE, guardedSafeParse, ownElementsOf, ownEntriesOf } from "./own-key-read.js";
@@ -156,8 +156,17 @@ function configDValueFlaw(name, value, label) {
156
156
  }
157
157
  return { reason: "not_object", error: new Error(`${label} must be a JSON object (got ${kind}) — ${documentWayOut(name)}`) };
158
158
  }
159
- /** 读一个 `config.d/<名>.json` → 读数:路径上没有任何条目 ⇒ 缺席;在场 ⇒ {@link judgeConfigDTextReading};在场却读不出 ⇒ 被拒,词 `io` —— 非 ENOENT 的 fs 错误,以及悬空的符号链接
160
- * (文档本身或 `config.d` 目录:内核跟链接读到 ENOENT,但条目在场;句子是 `danglingSymlinkSentence`(`./atomic-write`)那一句,与目录迁移的读口同一句)。缺席与悬空的分辨在 `readTextIfPresent`。 */
159
+ /** 在场却读不出的读数:词 `io`(非 `ENOENT` 的 fs 错误,以及悬空的符号链接 —— 文档本身或 `config.d` 目录:内核跟链接读到 `ENOENT`,但条目在场;句子是 `danglingSymlinkSentence`(`./atomic-write`)那一句,
160
+ * 与目录迁移的读口同一句)。`thrown` 是读文本抛的那个错原样,指纹取整句的哈希。异步 / 同步两条读口共用。 */
161
+ function refusedIo(thrown) {
162
+ const error = messageOf(thrown);
163
+ return { status: "refused", reason: "io", error, thrown, fingerprint: stableHash(error) };
164
+ }
165
+ /** 读文本之后的读数(异步 / 同步两条读口共用,**判官只此一处**):没有文本(路径上没有任何条目)⇒ 缺席;有 ⇒ {@link judgeConfigDTextReading}。 */
166
+ function readingOfText(name, file, text) {
167
+ return text === undefined ? { status: "absent" } : judgeConfigDTextReading(name, text, file);
168
+ }
169
+ /** 读一个 `config.d/<名>.json` → 读数:路径上没有任何条目 ⇒ 缺席;在场 ⇒ {@link judgeConfigDTextReading};在场却读不出 ⇒ 被拒,词 `io`({@link refusedIo})。缺席与悬空的分辨在 `readTextIfPresent` 的缺席尾。 */
161
170
  export async function readConfigDFileReading(root, name) {
162
171
  const file = configDPath(root, name);
163
172
  let text;
@@ -165,10 +174,22 @@ export async function readConfigDFileReading(root, name) {
165
174
  text = await readTextIfPresent(file, [path.dirname(file)]);
166
175
  }
167
176
  catch (e) {
168
- const error = messageOf(e);
169
- return { status: "refused", reason: "io", error, thrown: e, fingerprint: stableHash(error) };
177
+ return refusedIo(e);
170
178
  }
171
- return text === undefined ? { status: "absent" } : judgeConfigDTextReading(name, text, file);
179
+ return readingOfText(name, file, text);
180
+ }
181
+ /** {@link readConfigDFileReading} 的同步孪生(`./node` 出口;壳在同步具名锁里读 `config.d` 文档要它):**同一只纯判**({@link readingOfText} → {@link judgeConfigDTextReading})、**同一个缺席尾**
182
+ * (`readTextIfPresentSync` 与异步读者共用 `absentUnlessDangling`),只有读文本那一行 I/O 不同。名字不是 `config.d` 里能放的文档名 ⇒ 同步抛(与异步口的拒绝同一个错)。 */
183
+ export function readConfigDFileReadingSync(root, name) {
184
+ const file = configDPath(root, name);
185
+ let text;
186
+ try {
187
+ text = readTextIfPresentSync(file, [path.dirname(file)]);
188
+ }
189
+ catch (e) {
190
+ return refusedIo(e);
191
+ }
192
+ return readingOfText(name, file, text);
172
193
  }
173
194
  /** 抛出口:读一个 `config.d/<名>.json`:路径上没有任何条目 ⇒ `undefined`;在场 ⇒ 它的值;被拒 ⇒ 抛读数的 `thrown`(见 {@link readConfigDFileReading})。 */
174
195
  export async function readConfigDFile(root, name) {
@@ -570,6 +570,7 @@ export declare const DOMAIN_READ_FALLBACK: {
570
570
  readonly projects: "throw";
571
571
  readonly prompts: "default";
572
572
  readonly limits: "throw";
573
+ readonly a2a: "default";
573
574
  };
574
575
  /** Per-domain ENTRY-SALVAGE policy (12.3.0, S-1494) — may the view-face domain reader ({@link parseDomainSalvaged}) drop single bad
575
576
  * entries (an array element, a Record value, and — recursively — a bad entry inside a surviving entry) and read the rest?
@@ -605,7 +606,10 @@ export declare const DOMAIN_READ_FALLBACK: {
605
606
  * - governance: NONE — a dropped commandPolicy / approvalRequire row is a guardrail silently gone (放权,不许).
606
607
  * - entitlement: NONE — a dropped tier / subject / binding / budget group changes who may do what (放权,不许).
607
608
  * - execution: NONE — a dropped lane / public key changes the execution mandate (放权,不许).
608
- * - limits: NONE — a dropped allowlist row changes a published ceiling (the domain is the one publishing seat of those ceilings). */
609
+ * - limits: NONE — a dropped allowlist row changes a published ceiling (the domain is the one publishing seat of those ceilings).
610
+ * - a2a: SALVAGE (18.0.0) — the peer roster is a capability catalog like mcp: a dropped peer is one remote agent fewer. A peer's
611
+ * credential references (`headerRefs`) are in {@link ATOMIC_SALVAGE}: a bad reference drops the whole peer, never the reference
612
+ * alone (a peer kept with half of its credentials is the wrong failure direction). */
609
613
  export declare const DOMAIN_ENTRY_SALVAGE: {
610
614
  readonly models: "salvage";
611
615
  readonly rosters: "none";
@@ -623,6 +627,7 @@ export declare const DOMAIN_ENTRY_SALVAGE: {
623
627
  readonly projects: "salvage";
624
628
  readonly prompts: "none";
625
629
  readonly limits: "none";
630
+ readonly a2a: "salvage";
626
631
  };
627
632
  /** 一个读者向店要文档的口:配置域,以及墓碑表里的retired 域({@link RETIRED_DOMAINS})—— 后者只为让墓碑看见没迁的旧文档。 */
628
633
  export type StoredDocumentReader = (d: DomainName | RetiredDomainName) => unknown;
@@ -23,7 +23,7 @@ import { isJsonArray, isPlainJsonObject, jsonShapeOf } from "./plain-json-object
23
23
  import { carriesUserinfo, secretScanForms } from "./secret-scan.js"; // package-private normalization (both scanners read it) + the userinfo judgement (not in the barrel)
24
24
  import { sha256Hex } from "./sha256.js";
25
25
  import { unwrapSchema } from "./zod-unwrap.js"; // package-private: the ONE reading of zod's wrapper layers (salvage + the one-time migrations share it)
26
- import { DOMAIN_SCHEMAS, InfraCostRates, LimitsConfig, PRIVATE_KEY_PEM_HEADER, RETIRED_DOMAINS, SkillsManifestSchema, containsSecretToken, isDomainName, isRetiredDomainName, renderQuotedSentence, retiredDomainSentence } from "./types.js";
26
+ import { ATOMIC_SALVAGE, DOMAIN_SCHEMAS, InfraCostRates, LimitsConfig, PRIVATE_KEY_PEM_HEADER, RETIRED_DOMAINS, SkillsManifestSchema, containsSecretToken, isDomainName, isRetiredDomainName, renderQuotedSentence, retiredDomainSentence } from "./types.js";
27
27
  /** Redact secret-shaped tokens from a validation MESSAGE — a zod error echoes the offending value (e.g.
28
28
  * `received 'sk-live-…'`), so a secret pasted into the WRONG field would leak via the error string. Redacts
29
29
  * space-spanning auth credentials (`Bearer <token>`) FIRST (the token pass can't see across the space), then
@@ -197,6 +197,10 @@ export const DOMAIN_WRITE_ROLE = {
197
197
  // limits (0.19.0): editor —— 与它的前身 runtime 域(11.0.0 retired)同档,刻意不抬 publisher:本域是纯数值
198
198
  // 旋钮,无 prompt 注入面、无凭据、无可执行内容;真正的执法点在消费方的限额闸,发布闸(PUBLISH_MODE /
199
199
  // 审批流)才是"要不要多一道人"的属主。default 即 editor,故不列条目;此注释即该裁定的落点。
200
+ // a2a (18.0.0): a peer is a remote agent whose skills are mounted into the agent's tool surface, plus the env-NAMEs that
201
+ // carry its credentials — the same executable-surface + credential-reference profile as mcp, so the same raised gate.
202
+ // Written out (not left to the default) because the default would be the WRONG answer here.
203
+ a2a: "publisher",
200
204
  };
201
205
  /** The minimum role required to write `domain` (the raised gate for skills/mcp, else editor). */
202
206
  export function domainWriteRole(domain) {
@@ -495,6 +499,9 @@ function salvageLayerUnsafe(schema, raw) {
495
499
  const patch = {};
496
500
  for (const [field, fieldSchema] of Object.entries(shape)) {
497
501
  const collection = unwrapSchema(fieldSchema);
502
+ // 凭据引用集合(按 schema 身份,见 ATOMIC_SALVAGE)不下钻:它是一个值,坏一项 ⇒ 它所属的条目在上一层整条判不过、整条丢。
503
+ if (ATOMIC_SALVAGE.has(collection))
504
+ continue;
498
505
  const isArray = collection instanceof z.ZodArray;
499
506
  if (!isArray && !(collection instanceof z.ZodRecord))
500
507
  continue; // 不是条目集合:连读都不读(少触发一次访问器)
@@ -1006,6 +1013,8 @@ export const DOMAIN_READ_FALLBACK = {
1006
1013
  // 降级成空域 = 中央发布的成本/限流天花板整片蒸发,消费方回自己的 env 底(可能宽得多)= 预算轴放权。
1007
1014
  // 消费方 fail-static 在上一份好配置严格更好。
1008
1015
  limits: "throw",
1016
+ // a2a(18.0.0):能力目录(同 mcp)—— 整域读不进 ⇒ 空名册 = 不拨任何对端(能力消失,fail-closed;没有闸因此丢掉)。
1017
+ a2a: "default",
1009
1018
  };
1010
1019
  /** Per-domain ENTRY-SALVAGE policy (12.3.0, S-1494) — may the view-face domain reader ({@link parseDomainSalvaged}) drop single bad
1011
1020
  * entries (an array element, a Record value, and — recursively — a bad entry inside a surviving entry) and read the rest?
@@ -1041,7 +1050,10 @@ export const DOMAIN_READ_FALLBACK = {
1041
1050
  * - governance: NONE — a dropped commandPolicy / approvalRequire row is a guardrail silently gone (放权,不许).
1042
1051
  * - entitlement: NONE — a dropped tier / subject / binding / budget group changes who may do what (放权,不许).
1043
1052
  * - execution: NONE — a dropped lane / public key changes the execution mandate (放权,不许).
1044
- * - limits: NONE — a dropped allowlist row changes a published ceiling (the domain is the one publishing seat of those ceilings). */
1053
+ * - limits: NONE — a dropped allowlist row changes a published ceiling (the domain is the one publishing seat of those ceilings).
1054
+ * - a2a: SALVAGE (18.0.0) — the peer roster is a capability catalog like mcp: a dropped peer is one remote agent fewer. A peer's
1055
+ * credential references (`headerRefs`) are in {@link ATOMIC_SALVAGE}: a bad reference drops the whole peer, never the reference
1056
+ * alone (a peer kept with half of its credentials is the wrong failure direction). */
1045
1057
  export const DOMAIN_ENTRY_SALVAGE = {
1046
1058
  models: "salvage",
1047
1059
  rosters: "none",
@@ -1059,6 +1071,7 @@ export const DOMAIN_ENTRY_SALVAGE = {
1059
1071
  projects: "salvage",
1060
1072
  prompts: "none",
1061
1073
  limits: "none",
1074
+ a2a: "salvage",
1062
1075
  };
1063
1076
  /**
1064
1077
  * Merge every domain into the effective config a consumer pulls. Shared by all store impls.
@@ -1111,6 +1124,7 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
1111
1124
  // `{}` 不下发中心层,default 不扩权)。
1112
1125
  prompts: parseOr("prompts", getRaw("prompts")),
1113
1126
  limits: parseOr("limits", getRaw("limits")),
1127
+ a2a: parseOr("a2a", getRaw("a2a")),
1114
1128
  };
1115
1129
  }
1116
1130
  /** READ-side tolerant judgment for a GET /effective **wire payload**(the {@link EffectiveWire} contract)at
@@ -15,7 +15,7 @@
15
15
  * 离线预置(引擎在本根起服之前铺好:镜像 / ConfigMap / 还原)时两份文档与域文档可按任意顺序写;运行时写序是定的:写者先写决定、再写 `config.d/models.json`
16
16
  * (都在它对 models.json 的那把锁里),迁移先建运行记录、再铸决定。
17
17
  * · **一份读法、一张闭集答案表**:决定只有 {@link EnvModelsDecision} 这一种答案(从字节读出的带同一次读的 `digest`,读不到字节的带 `errno`),
18
- * 读这份决定的三方 —— 引擎(本地配置腿强制这道闸之后;今天引擎还不读它)、一次性迁移 SS-M11、写者的计划助手 —— 吃同一个答案;它的身份(告警去重键、本地 etag 的贡献)只有一种拼法 {@link envModelsDecisionTag}。
18
+ * 读这份决定的三方 —— 服务端的本地配置腿(读它并强制这道闸)、一次性迁移 SS-M11、写者的计划助手 —— 吃同一个答案;它的身份(告警去重键、本地 etag 的贡献)只有一种拼法 {@link envModelsDecisionTag}。
19
19
  * · **运行记录按 id 一份**:「迁移 X 跑过」⇔ `<根>/config.migration.<X>.json` 在场({@link MigrationMarker});没有聚合文件、没有追加写;
20
20
  * 读不懂的记录按「跑过」处置(fail-closed)。记录由迁移以「不存在才建」写:同一个 id 只有一个进程建得成 —— 它是那条迁移在本根上唯一的执行者。
21
21
  * · 两份文档的形都是 strict 的:多一个键(含自有 `__proto__`)、拼错的词都读成 `bad_shape`,不剥、不猜;digest 对读到的**字节**求。名字只在这里定一次(引擎的目录信任判官取 {@link CONFIG_ROOT_DOCUMENTS})。
@@ -72,7 +72,7 @@ export type EnvModelsDecision = {
72
72
  readonly errno: string;
73
73
  };
74
74
  /**
75
- * 一份决定读数的**身份**(带标签的一种拼法):`absent` / `d:<digest>`(有字节的四臂)/ `io:<errno>`。引擎读这份决定时(今天引擎还不读),「每份内容只告警一次」的去重键与本地 etag 的贡献都取它 ——
75
+ * 一份决定读数的**身份**(带标签的一种拼法):`absent` / `d:<digest>`(有字节的四臂)/ `io:<errno>`。服务端的本地配置腿读这份决定时,「每份内容只告警一次」的去重键与本地 etag 的贡献都取它 ——
76
76
  * 来自同一次读,不再开文件(那就是第二个读者)。
77
77
  */
78
78
  export declare function envModelsDecisionTag(decision: EnvModelsDecision): string;
@@ -22,7 +22,7 @@ export const isDecisionWord = (v) => ENV_MODELS_DECISIONS.includes(v);
22
22
  /** 根级文档读不懂的三种原因(闭集):`bad_json` = 空 / 纯空白 / 不是 JSON;`bad_shape` = 是 JSON 但不是这份文档的形;`io` = 字节读不出(权限 —— 可信属主的 0000 文件读恒 `EACCES`,是持久的 —— 不是常规文件、悬空链接、瞬时读错)。 */
23
23
  export const ROOT_DOCUMENT_UNREADABLE_REASONS = ["bad_json", "bad_shape", "io"];
24
24
  /**
25
- * 一份决定读数的**身份**(带标签的一种拼法):`absent` / `d:<digest>`(有字节的四臂)/ `io:<errno>`。引擎读这份决定时(今天引擎还不读),「每份内容只告警一次」的去重键与本地 etag 的贡献都取它 ——
25
+ * 一份决定读数的**身份**(带标签的一种拼法):`absent` / `d:<digest>`(有字节的四臂)/ `io:<errno>`。服务端的本地配置腿读这份决定时,「每份内容只告警一次」的去重键与本地 etag 的贡献都取它 ——
26
26
  * 来自同一次读,不再开文件(那就是第二个读者)。
27
27
  */
28
28
  export function envModelsDecisionTag(decision) {
@@ -16,6 +16,8 @@ export interface ConfigRootIo {
16
16
  export declare const nodeConfigRootIo: ConfigRootIo;
17
17
  /** 配置根上 env 模型表决定的**唯一读者**(闭集答案见 `EnvModelsDecision`;从字节读出的答案带同一次读的 `digest`,io 带 `errno`)。从不抛。 */
18
18
  export declare function readEnvModelsDecision(root: string, io?: ConfigRootIo): Promise<EnvModelsDecision>;
19
+ /** {@link readEnvModelsDecision} 的**同步孪生**(壳在同步具名锁里写模型池,要在锁里读决定):同一个字节读法、同一个缺席律、同一个答案表;读真文件系统(不收注入座 —— 注入座是异步的)。从不抛。 */
20
+ export declare function readEnvModelsDecisionSync(root: string): EnvModelsDecision;
19
21
  /**
20
22
  * 一条一次性迁移的运行记录的**唯一读者**(文件 `<根>/config.migration.<id>.json`):不存在 ⇒ `absent`;`{"migration":<id>}` ⇒ `present`;别的 ⇒ `unreadable`
21
23
  * (坏 JSON / 坏形 —— id 对不上也算 / 读不出)。「跑过」只问这一份文件在不在、读不读得懂,不读任何聚合文件。读数从不抛;`id` 不是写运行记录的迁移(根级文档名单里没有它的记录)⇒
@@ -31,6 +33,11 @@ export type EnvModelsDecisionWrite = "overwrite" | "createIfAbsent";
31
33
  export declare function writeEnvModelsDecision(root: string, decision: EnvModelsDecisionWord, opts: {
32
34
  readonly write: EnvModelsDecisionWrite;
33
35
  }, io?: ConfigRootIo): Promise<"written" | "present">;
36
+ /** {@link writeEnvModelsDecision} 的**同步孪生**(壳在同步具名锁里写模型池:先写决定 `replaced`、再写目录,两次写在同一把锁里):同一份计划({@link planDecisionWrite})、同一对同步原子写原语(异步座调的也是它们);
37
+ * 写真文件系统(不收注入座)。返回值、抛的错与落盘结果与异步口逐项同。 */
38
+ export declare function writeEnvModelsDecisionSync(root: string, decision: EnvModelsDecisionWord, opts: {
39
+ readonly write: EnvModelsDecisionWrite;
40
+ }): "written" | "present";
34
41
  /** SS-M11 的结局(闭集,调用方穷举;本函数不出声,告警由调用方按结局发)。 */
35
42
  export type EnvModelsMigration =
36
43
  /** 运行记录已在:不跑。 */
@@ -107,7 +114,7 @@ export type EnvModelsMint =
107
114
  * 4. **独占创建运行记录** `{"migration":"SS-M11"}`:建成 ⇒ 本进程是本根**唯一的铸造者**;已在(`EEXIST`)⇒ `not_minter`,永不铸;别的失败 ⇒ `marker_write_failed`,不铸。
108
115
  * 5. 仅铸造者:D0 不是 `absent` ⇒ 不铸(读不懂的也不覆盖);M0 不管 ⇒ 不铸;M0 读不出 ⇒ 不铸(`models_unreadable`,带 errno(若有)与手工恢复句);否则**独占创建** `{"envModels":"replaced"}` —— 结局三词:`minted` / `exists`(只有 `EEXIST`)/
109
116
  * `failed`(带 errno 与手工恢复句)。铸不铸不看 env;env 只在铸成之后读一次、只读两槽,用来点名被顶替的 env 模型。
110
- * 运行记录在场之后,确认缺席 ≡ kept(读这份决定的一律这么读:引擎强制这道闸之后、迁移、写者)。
117
+ * 运行记录在场之后,确认缺席 ≡ kept(读这份决定的一律这么读:服务端的本地配置腿、迁移、写者)。
111
118
  * 撤销的终局按运行记录的创建者算:只有创建者能写确认;以运行记录在场为前提,创建者写完 / 判定不写 / 失败(结局 `minted` / `exists` / `skipped` / `models_unreadable` / `failed`,调用方各记一行)之后,删掉决定文件就是撤销且是终局(删掉运行记录再起服 ⇒ 迁移重跑,决定缺席即重铸);其它副本读到记录在场 = 已跑、跳过(或得 `EEXIST` 答 `not_minter`),它们的返回不构成终局;创建者当选到结局落定之间的窗口在挂起 / I/O 卡顿下无上界,窗口内唯一安全的撤销是写 `kept`(独占创建护住它;删掉的会被创建者照它先读的 D0 铸回);创建者在写确认之前崩溃 ⇒ 永久 `kept`(fail-closed,披露)。
112
119
  */
113
120
  export declare function migrateEnvModelsDecision(input: {
@@ -8,6 +8,8 @@
8
8
  * 读恒 `EACCES`,是**持久**的 —— 不是常规文件、悬空符号链接、瞬时错)⇒ `unreadable io`;字节在 ⇒ 文本读法(`./config-root-documents`;决定的答案带同一次读的 `digest`)。
9
9
  * 读数从不抛(`readMigrationMarker` 只对不写运行记录的 id 抛 `TypeError` —— 调用方的错,不拿它拼路径)。**引擎在读之前先过它自己的目录信任判官**(属主 / 写位 / 常规文件);
10
10
  * 本函数不判属主。确认文件的字节只在这里被打开。
11
+ * · {@link readEnvModelsDecisionSync} / {@link writeEnvModelsDecisionSync} —— 决定文档读 / 写的**同步孪生**(壳在同步具名锁里写模型池:先写决定 `replaced`、再写目录,两次写在同一把锁里 —— 要在锁里读、在锁里写):
12
+ * 同一个字节读法、同一个缺席律、同一份答案表、同一份写计划、同一对同步原子写原语(异步座的写本来就是它们的异步包装);读写真文件系统,不收注入座(注入座是异步的,`ConfigRootIo` 不动)。
11
13
  * · {@link writeEnvModelsDecision} —— 决定文件的唯一写函数(迁移铸确认也经它):写者(用户确认之后)用 `overwrite`(原子改名覆盖,读不懂的旧文件整份换掉);
12
14
  * `createIfAbsent` 不存在才建(已在 ⇒ `present`;迁移铸确认用的就是这一种)。运行记录不经它 —— 由迁移独占创建。
13
15
  * 模式与 `config.d` 域文档同一条规则(原子写器的 `config-document` 类:新建 0o644 经进程 umask、更严的 umask 只会更严;已有的文件沿用它原来的模式)—— 写者不要自己写这份文件:
@@ -16,6 +18,7 @@
16
18
  * 运行记录 `config.migration.SS-M11.json` —— 建成的那个进程是本根唯一的铸造者,且只按它选举之前读到的决定铸;别的进程见记录已在即停、永不铸。
17
19
  * 撤销的终局按创建者算(见 {@link migrateEnvModelsDecision}):运行记录在场、创建者的结局落定之后,删掉决定文件与写 `kept` 同义、是终局(删掉运行记录再起服会重跑迁移);其它副本的返回不构成终局。
18
20
  */
21
+ import { readFileSync, statSync } from "node:fs";
19
22
  import { readFile, stat } from "node:fs/promises";
20
23
  import path from "node:path";
21
24
  import { createFileExclusive, ensureOwnerOnlyDirectory, resolveSymlinkTarget, writeFileAtomic } from "./atomic-write.js";
@@ -31,19 +34,32 @@ const ROOT_DOCUMENT_CLASS = {
31
34
  [ENV_MODELS_DOCUMENT]: "config-document",
32
35
  [migrationMarkerDocument("SS-M11")]: "config-document",
33
36
  };
37
+ /** 「不是常规文件」的拒句(异步读座与同步读口共用一句)。 */
38
+ const notRegularFile = (file) => new Error(`${file} is not a regular file`);
39
+ /** 真文件系统上的两只**同步**写原语(模式类表 {@link ROOT_DOCUMENT_CLASS} 只经这一对取):原子覆盖写 / 独占创建。异步座 {@link nodeConfigRootIo} 与同步写口 {@link writeEnvModelsDecisionSync} 调的是同一对 ——
40
+ * 原子写器本来就是同步实现,异步座的两只写只是它的异步包装。 */
41
+ const writeRootDocumentSync = (document, file, text) => writeFileAtomic(file, text, ROOT_DOCUMENT_CLASS[document]);
42
+ const createRootDocumentSync = (document, file, text) => createFileExclusive(file, text, ROOT_DOCUMENT_CLASS[document]);
34
43
  /** 真文件系统的绑定:读先过本包的符号链接判法(链接 ⇒ 它指向的真文件;悬空 ⇒ 抛,不是缺席),只读常规文件;写走本包唯一的原子写器。 */
35
44
  export const nodeConfigRootIo = {
36
45
  readFile: async (file) => {
37
46
  const real = resolveSymlinkTarget(file);
38
47
  const st = await stat(real);
39
48
  if (!st.isFile())
40
- throw new Error(`${file} is not a regular file`);
49
+ throw notRegularFile(file);
41
50
  return readFile(real);
42
51
  },
43
- writeDocument: async (document, file, text) => writeFileAtomic(file, text, ROOT_DOCUMENT_CLASS[document]),
44
- createDocument: async (document, file, text) => createFileExclusive(file, text, ROOT_DOCUMENT_CLASS[document]),
52
+ writeDocument: async (document, file, text) => writeRootDocumentSync(document, file, text),
53
+ createDocument: async (document, file, text) => createRootDocumentSync(document, file, text),
45
54
  ensureDirectory: async (dir) => ensureOwnerOnlyDirectory(dir),
46
55
  };
56
+ /** {@link nodeConfigRootIo}`.readFile` 的同步孪生(同步读口 {@link readEnvModelsDecisionSync} 用):同一条判法(符号链接 ⇒ 真文件,悬空抛;只读常规文件),只有读字节那一步同步。 */
57
+ function readRootFileSync(file) {
58
+ const real = resolveSymlinkTarget(file);
59
+ if (!statSync(real).isFile())
60
+ throw notRegularFile(file);
61
+ return readFileSync(real);
62
+ }
47
63
  const decisionFile = (root) => path.join(root, `${ENV_MODELS_DOCUMENT}.json`);
48
64
  const markerFile = (root, id) => path.join(root, `${migrationMarkerDocument(id)}.json`);
49
65
  /** 一个被抛出的东西的 errno:**只认自有的**字符串 `code`(经内核的键读法);读不出(会抛的 `code` getter、已撤销的 Proxy)、只靠继承、不是串 ⇒ `undefined`。从不抛(读者「从不抛」靠它)。 */
@@ -66,33 +82,52 @@ const errnoOf = (e) => {
66
82
  }
67
83
  }
68
84
  };
69
- /** 配置根下一份文件(两份根级文档与 `config.d/models.json`)的字节 —— 本文件唯一的缺席律:不存在(只有 `ENOENT`)⇒ `absent`;
70
- * 别的读不出 ⇒ `io`(带 errno 与捕获的错,调用方要句子时据它铸)。 */
85
+ /** 读字节失败之后怎么答 —— 本文件唯一的缺席律(异步 {@link readRootBytes} 与同步 {@link readRootBytesSync} 共用):不存在(只有 `ENOENT`)⇒ `absent`;别的读不出 ⇒ `io`(带 errno 与捕获的错,调用方要句子时据它铸)。 */
86
+ function rootBytesFailure(e) {
87
+ const errno = errnoOf(e);
88
+ return errno === "ENOENT" ? { kind: "absent" } : { kind: "io", errno, cause: e };
89
+ }
90
+ /** 经注入座读一份文件的字节(异步);失败的分类是 {@link rootBytesFailure}。 */
71
91
  async function readRootBytes(io, file) {
72
92
  try {
73
93
  return { kind: "bytes", bytes: await io.readFile(file) };
74
94
  }
75
95
  catch (e) {
76
- const errno = errnoOf(e);
77
- return errno === "ENOENT" ? { kind: "absent" } : { kind: "io", errno, cause: e };
96
+ return rootBytesFailure(e);
78
97
  }
79
98
  }
80
- /** 配置根上 env 模型表决定的**唯一读者**(闭集答案见 `EnvModelsDecision`;从字节读出的答案带同一次读的 `digest`,io 带 `errno`)。从不抛。 */
81
- export async function readEnvModelsDecision(root, io = nodeConfigRootIo) {
82
- const bytes = await readRootBytes(io, decisionFile(root));
83
- switch (bytes.kind) {
99
+ /** 真文件系统上读一份文件的字节(同步);失败的分类是 {@link rootBytesFailure}。 */
100
+ function readRootBytesSync(file) {
101
+ try {
102
+ return { kind: "bytes", bytes: readRootFileSync(file) };
103
+ }
104
+ catch (e) {
105
+ return rootBytesFailure(e);
106
+ }
107
+ }
108
+ /** 决定文档的字节读数 → 答案(异步 / 同步两只读者共用,字节读法 {@link readEnvModelsDecisionBytes} 只在这里被调)。 */
109
+ function decisionOfBytes(read) {
110
+ switch (read.kind) {
84
111
  case "absent":
85
112
  return { kind: "absent" };
86
113
  case "io":
87
- return ioDecision(bytes.errno);
114
+ return ioDecision(read.errno);
88
115
  case "bytes":
89
- return readEnvModelsDecisionBytes(bytes.bytes);
116
+ return readEnvModelsDecisionBytes(read.bytes);
90
117
  default: {
91
- const unhandled = bytes;
118
+ const unhandled = read;
92
119
  throw new Error(`readEnvModelsDecision: unhandled reading ${JSON.stringify(unhandled)}`);
93
120
  }
94
121
  }
95
122
  }
123
+ /** 配置根上 env 模型表决定的**唯一读者**(闭集答案见 `EnvModelsDecision`;从字节读出的答案带同一次读的 `digest`,io 带 `errno`)。从不抛。 */
124
+ export async function readEnvModelsDecision(root, io = nodeConfigRootIo) {
125
+ return decisionOfBytes(await readRootBytes(io, decisionFile(root)));
126
+ }
127
+ /** {@link readEnvModelsDecision} 的**同步孪生**(壳在同步具名锁里写模型池,要在锁里读决定):同一个字节读法、同一个缺席律、同一个答案表;读真文件系统(不收注入座 —— 注入座是异步的)。从不抛。 */
128
+ export function readEnvModelsDecisionSync(root) {
129
+ return decisionOfBytes(readRootBytesSync(decisionFile(root)));
130
+ }
96
131
  /**
97
132
  * 一条一次性迁移的运行记录的**唯一读者**(文件 `<根>/config.migration.<id>.json`):不存在 ⇒ `absent`;`{"migration":<id>}` ⇒ `present`;别的 ⇒ `unreadable`
98
133
  * (坏 JSON / 坏形 —— id 对不上也算 / 读不出)。「跑过」只问这一份文件在不在、读不读得懂,不读任何聚合文件。读数从不抛;`id` 不是写运行记录的迁移(根级文档名单里没有它的记录)⇒
@@ -115,13 +150,12 @@ export async function readMigrationMarker(root, id, io = nodeConfigRootIo) {
115
150
  }
116
151
  }
117
152
  }
118
- /**
119
- * 写配置根上的 env 模型表决定 —— 决定文件的**唯一写函数**(迁移铸确认也经它的 `createIfAbsent`;运行记录不归它,见文件头)。`overwrite` 恒答 `written`;`createIfAbsent` 在文档已在(`EEXIST`)时答 `present`(一字不动)。
120
- * 决定词 / 写法不在闭集 ⇒ `TypeError`(一字不写)。写失败 ⇒ 抛本包的文件系统失败句(点名路径)。根须已存在。
121
- */
122
- export async function writeEnvModelsDecision(root, decision, opts, io = nodeConfigRootIo) {
153
+ /** 写决定的**计划**(异步 {@link writeEnvModelsDecision} 与同步 {@link writeEnvModelsDecisionSync} 共用,三件事只在这里):决定词与写法都在闭集里(不在 ⇒ `TypeError`,调用方的错,一字不写)· 铸文本 · 写失败 ⇒ 本包的文件系统失败句(点名路径)。
154
+ * `who` 只用来在 `TypeError` 里点名调用的是哪一只。 */
155
+ function planDecisionWrite(who, root, decision, opts) {
156
+ // 校验次序 = 上一个已发版本里异步口的次序(决定词 → 取路径 → 写法),逐位不动:两处同时坏的输入,先抛的错不变
123
157
  if (!isDecisionWord(decision))
124
- throw new TypeError(`writeEnvModelsDecision: decision must be one of ${ENV_MODELS_DECISIONS.join(" / ")} (got ${JSON.stringify(decision)})`);
158
+ throw new TypeError(`${who}: decision must be one of ${ENV_MODELS_DECISIONS.join(" / ")} (got ${JSON.stringify(decision)})`);
125
159
  const file = decisionFile(root);
126
160
  const text = configDDocumentText({ envModels: decision });
127
161
  const loud = (e) => {
@@ -130,16 +164,64 @@ export async function writeEnvModelsDecision(root, decision, opts, io = nodeConf
130
164
  const write = opts.write;
131
165
  switch (write) {
132
166
  case "overwrite":
133
- await io.writeDocument(ENV_MODELS_DOCUMENT, file, text).catch(loud);
167
+ case "createIfAbsent":
168
+ break;
169
+ default: {
170
+ const unhandled = write;
171
+ throw new TypeError(`${who}: write must be "overwrite" or "createIfAbsent" (got ${JSON.stringify(unhandled)})`);
172
+ }
173
+ }
174
+ return { write, file, text, loud };
175
+ }
176
+ /**
177
+ * 写配置根上的 env 模型表决定 —— 决定文件的**唯一写函数**(迁移铸确认也经它的 `createIfAbsent`;运行记录不归它,见文件头)。`overwrite` 恒答 `written`;`createIfAbsent` 在文档已在(`EEXIST`)时答 `present`(一字不动)。
178
+ * 决定词 / 写法不在闭集 ⇒ `TypeError`(一字不写)。写失败 ⇒ 抛本包的文件系统失败句(点名路径)。根须已存在。
179
+ */
180
+ export async function writeEnvModelsDecision(root, decision, opts, io = nodeConfigRootIo) {
181
+ const plan = planDecisionWrite("writeEnvModelsDecision", root, decision, opts);
182
+ const write = plan.write;
183
+ switch (write) {
184
+ case "overwrite":
185
+ await io.writeDocument(ENV_MODELS_DOCUMENT, plan.file, plan.text).catch(plan.loud);
134
186
  return "written";
135
187
  case "createIfAbsent":
136
- return (await io.createDocument(ENV_MODELS_DOCUMENT, file, text).catch(loud)) === "created" ? "written" : "present";
188
+ return (await io.createDocument(ENV_MODELS_DOCUMENT, plan.file, plan.text).catch(plan.loud)) === "created" ? "written" : "present";
137
189
  default: {
138
190
  const unhandled = write;
139
191
  throw new TypeError(`writeEnvModelsDecision: write must be "overwrite" or "createIfAbsent" (got ${JSON.stringify(unhandled)})`);
140
192
  }
141
193
  }
142
194
  }
195
+ /** {@link writeEnvModelsDecision} 的**同步孪生**(壳在同步具名锁里写模型池:先写决定 `replaced`、再写目录,两次写在同一把锁里):同一份计划({@link planDecisionWrite})、同一对同步原子写原语(异步座调的也是它们);
196
+ * 写真文件系统(不收注入座)。返回值、抛的错与落盘结果与异步口逐项同。 */
197
+ export function writeEnvModelsDecisionSync(root, decision, opts) {
198
+ const plan = planDecisionWrite("writeEnvModelsDecisionSync", root, decision, opts);
199
+ const write = plan.write;
200
+ switch (write) {
201
+ case "overwrite":
202
+ try {
203
+ writeRootDocumentSync(ENV_MODELS_DOCUMENT, plan.file, plan.text);
204
+ }
205
+ catch (e) {
206
+ return plan.loud(e);
207
+ }
208
+ return "written";
209
+ case "createIfAbsent": {
210
+ let created;
211
+ try {
212
+ created = createRootDocumentSync(ENV_MODELS_DOCUMENT, plan.file, plan.text);
213
+ }
214
+ catch (e) {
215
+ return plan.loud(e);
216
+ }
217
+ return created === "created" ? "written" : "present";
218
+ }
219
+ default: {
220
+ const unhandled = write;
221
+ throw new TypeError(`writeEnvModelsDecisionSync: write must be "overwrite" or "createIfAbsent" (got ${JSON.stringify(unhandled)})`);
222
+ }
223
+ }
224
+ }
143
225
  /** 手工恢复句(迁移建不成收据 / 建不成决定时随结局交出;调用方原样放进它那一行告警):点名决定文件与两种写法。 */
144
226
  const wayOutFor = (root) => `write ${decisionFile(root)} by hand — {"envModels":"replaced"} lets the catalog replace the env models, {"envModels":"kept"} keeps them`;
145
227
  /** 读 `config.d/models.json` 一次(字节经 {@link readRootBytes} —— 与两份根级文档同一条缺席律;文本过 `config.d` 文档的那一只文本判 —— 与文件仓库同一读法):
@@ -179,7 +261,7 @@ async function readModelsDocument(io, root) {
179
261
  * 4. **独占创建运行记录** `{"migration":"SS-M11"}`:建成 ⇒ 本进程是本根**唯一的铸造者**;已在(`EEXIST`)⇒ `not_minter`,永不铸;别的失败 ⇒ `marker_write_failed`,不铸。
180
262
  * 5. 仅铸造者:D0 不是 `absent` ⇒ 不铸(读不懂的也不覆盖);M0 不管 ⇒ 不铸;M0 读不出 ⇒ 不铸(`models_unreadable`,带 errno(若有)与手工恢复句);否则**独占创建** `{"envModels":"replaced"}` —— 结局三词:`minted` / `exists`(只有 `EEXIST`)/
181
263
  * `failed`(带 errno 与手工恢复句)。铸不铸不看 env;env 只在铸成之后读一次、只读两槽,用来点名被顶替的 env 模型。
182
- * 运行记录在场之后,确认缺席 ≡ kept(读这份决定的一律这么读:引擎强制这道闸之后、迁移、写者)。
264
+ * 运行记录在场之后,确认缺席 ≡ kept(读这份决定的一律这么读:服务端的本地配置腿、迁移、写者)。
183
265
  * 撤销的终局按运行记录的创建者算:只有创建者能写确认;以运行记录在场为前提,创建者写完 / 判定不写 / 失败(结局 `minted` / `exists` / `skipped` / `models_unreadable` / `failed`,调用方各记一行)之后,删掉决定文件就是撤销且是终局(删掉运行记录再起服 ⇒ 迁移重跑,决定缺席即重铸);其它副本读到记录在场 = 已跑、跳过(或得 `EEXIST` 答 `not_minter`),它们的返回不构成终局;创建者当选到结局落定之间的窗口在挂起 / I/O 卡顿下无上界,窗口内唯一安全的撤销是写 `kept`(独占创建护住它;删掉的会被创建者照它先读的 D0 铸回);创建者在写确认之前崩溃 ⇒ 永久 `kept`(fail-closed,披露)。
184
266
  */
185
267
  export async function migrateEnvModelsDecision(input) {