@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
@@ -0,0 +1,828 @@
1
+ /**
2
+ * @sema-agent/settings-schema/one-time-migrations —— 一次性迁移族(11.0.0)。持久面唯一允许的过渡形,**窗关之后的下一个 major 整族删**。
3
+ *
4
+ * ## 律(一条,写在最前:什么变少了)
5
+ * **读路径上零抬升臂。** 旧形只有一种去处:这里登记的一次性迁移把它改写成新形(调用方写回、幂等、登记窗);读写两面遇旧形一律
6
+ * 响亮拒,拒句点名迁移 id(墓碑:retired 域 `RETIRED_DOMAINS` / 域 schema 里的retired 键墓碑字段 / 拒句尾的迁移指路)。11.0.0 之前,同样的
7
+ * 旧形由读路径上五只常驻臂每次读都抬一遍(治理三闸从 `runtime` 抬升、未知键扫描为 `runtime` 豁免、hosts 夹紧、调度店 durable 键重铸、
8
+ * workers 6.x 读时抬升),窗口只写在散文里、几个 major 都没关上。现在常驻臂 0 只;唯一的读时改写是调度店的 SS-M3(登记在本表、
9
+ * lazy-on-read,理由见那一行),随本族一起删。
10
+ *
11
+ * **迁移只认形、不认义**:每一步的输入形在新契约下非法、输出形合法 ⇒ 重跑恒等,新写入的数据永远不会被当成旧数据再改一次,不需要版本围栏;
12
+ * 同一串字节换意思的改动一律**换表示**(SS-M6 因此是「`atModelAllowlist` → `atMention`」换字段,而不是翻 `[]` 的义)。
13
+ *
14
+ * ## 入口(纯函数:不读环境、不读墙钟、不改入参、**不抬任何版本号**)
15
+ * · {@link migrateStoredDocuments} —— 一组**存储文档**(域名 → 文档)。一个 scope 的全部草稿、一个 principal 层(个人 models / mcp /
16
+ * skills / plugins)、回滚史里的一条单域行,各调一次;`coverage` 说这组文档是不是一个 scope 的全集(只有 SS-M1 要知道:缺席的
17
+ * `governance` 是「从未设过」还是「没给」)。缺的域 = 不动。
18
+ * · {@link migrateEffectiveSnapshot} —— 一份**冻结的 effective**(发布快照、回滚历史里的整份快照、引擎的 last-known-good 文件)。改写规则
19
+ * 与草稿同(零语义变 ⇒ 不越过发布审批:改的是表示,不是已批准的内容);不从草稿重建;`version` / `updatedAt` 与其余信封键原样。
20
+ * · {@link migrateSchedulerRecords} —— 调度店的记录(SS-M3;`parseSchedulerStore` 读时调它)。
21
+ * 每条改动出一张 {@link MigrationReceipt}(闭集 `appliedAs` 词,只点名坐标、不带值);**没改的文档原样(同一引用)返回**,被移除的文档
22
+ * (retired 域)从结果里消失 —— 调用方据此判「哪份要写回、哪份要删」。**版本号归调用方**:草稿版本(待审的发布申请据它判「草稿动没动」)、
23
+ * 快照的 `version` / `globalRevision` 一概不动 —— 迁移改的是表示,要不要因此抬版本是调用方的决定,本包不替它做。
24
+ *
25
+ * ## 窗(规则写在这里一次,日期只在登记表)
26
+ * 窗 = 引入版本的发布日 + 30 天(`removableAfter`);窗关之后的**下一个 major** 删整族(删什么 = 那一行的 `removal`;导出名删除是本包的
27
+ * 硬破坏,所以不是 minor)。未发布形:`releasedOn` / `removableAfter` 两格写 `null`,抬 `package.json` 到 `introducedIn` 的发布准备提交
28
+ * 回填。到期门 `test/one-time-migration-window-gate.test.ts`:今天晚于 `removableAfter` ⇒ 红(点名 `removal`);`package.json` ≥
29
+ * `introducedIn` 而两格仍 `null` ⇒ 红(回填门)。删码之后存量若还带旧形,由那一行 `afterRemoval` 点名的**既有**响亮臂接住。
30
+ */
31
+ import { z } from "zod";
32
+ import { outgoingPath, redactSecrets } from "./config-fns.js";
33
+ import { LANE_ENV_OWNER, STORE_ENV_KEYS, isLaneEnvKey } from "./execution-lane-index.js";
34
+ import { EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, STORE_BACKENDS } from "./execution-lanes.js";
35
+ import { INTEGER } from "./integer.js";
36
+ import { DOMAIN_SCHEMAS, HOSTS_ARRAY_MAX, HOST_NAME_MAX, NON_SECRET_URL, isDomainName, isRetiredDomainName } from "./types.js";
37
+ import { workerRowVerdict } from "./worker-row-verdict.js";
38
+ import { unwrapSchema } from "./zod-unwrap.js";
39
+ export { MIGRATION_APPLIED_AS, ONE_TIME_MIGRATION_IDS } from "./migration-receipt.js";
40
+ export { migrateSchedulerRecords } from "./scheduler-store-migration.js";
41
+ // ── 登记表 ─────────────────────────────────────────────────────────────────────────────────────────────
42
+ /** wire 删码位的 id(闭集;`W` = wire):没有存储形要改写、没有收据,与数据迁移同表登记只为让「什么时候删、删什么、删后谁接住」只写一处。 */
43
+ export const WIRE_RETIREMENT_IDS = ["SS-W1"];
44
+ /** 一次性迁移登记表 —— 本族各条窗的**唯一属主**。别处只写「窗见 `ONE_TIME_MIGRATIONS` 登记表」。 */
45
+ export const ONE_TIME_MIGRATIONS = [
46
+ {
47
+ id: "SS-M1",
48
+ oldForm: "refused",
49
+ introducedIn: "11.0.0",
50
+ releasedOn: "2026-10-10",
51
+ removableAfter: "2026-11-09",
52
+ rewrites: "the retired `runtime` domain document: the governance trio (autonomy / commandPolicy / approvalRequire) moves into `governance` when that domain " +
53
+ "was never set (else it is dropped — the set governance won); each of the five limit keys moves into `limits` when `limits` lacks it (else it is " +
54
+ "dropped — limits won); keys the runtime schema never read are dropped; then the runtime document is removed. Needs the whole scope " +
55
+ "(coverage \"scope\"); a partial set without both governance and limits is refused",
56
+ removal: "the next major after the window: delete the SS-M1 step (its private 10.x runtime schema included) and the `coverage` option of migrateStoredDocuments (SS-M1 is its only " +
57
+ "reader); RETIRED_DOMAINS stays — the SS-M1 clause leaves its sentence",
58
+ afterRemoval: "the RETIRED_DOMAINS tombstone (permanent): parseDomain / buildEffective / readEffectiveWire / FileConfigStore / readBundle refuse a runtime document that carries " +
59
+ "facts (RetiredDomainError). A rollback-history row that holds only a runtime document cannot be migrated on its own — rolling back to it is refused " +
60
+ "by the same tombstone (unrecoverable by design)",
61
+ },
62
+ {
63
+ id: "SS-M2",
64
+ oldForm: "refused",
65
+ introducedIn: "11.0.0",
66
+ releasedOn: "2026-10-10",
67
+ removableAfter: "2026-11-09",
68
+ rewrites: "hosts: an entry whose name is longer than 63 characters is removed, and an array over 1024 entries keeps its first 1024 — exactly what the " +
69
+ "read-side clamp did on every read since 0.8.1",
70
+ removal: "the next major after the window: delete the SS-M2 step; the two hosts bound messages lose their SS-M2 clause",
71
+ afterRemoval: "the hosts schema bounds (read and write): an over-long name drops its entry on read (`entry-dropped`), an over-long array degrades the hosts " +
72
+ "domain to its default (`domain-defaulted`)",
73
+ },
74
+ {
75
+ id: "SS-M3",
76
+ oldForm: "rewritten-on-read",
77
+ introducedIn: "11.0.0",
78
+ releasedOn: "2026-10-10",
79
+ removableAfter: "2026-11-09",
80
+ rewrites: "scheduler store: a durable record (lifetime not \"session\") still keyed by its session chain gets scope := principal ?? \"default\". Lazy-on-read: " +
81
+ "parseSchedulerStore applies it and the next writer persists it (a separate migration write would only add a competing writer) — the one read-time " +
82
+ "rewrite left in the package",
83
+ removal: "the next major after the window: delete scheduler-store-migration.ts, its call in parseSchedulerStore and the `one-time-migration` arm of SchedulerParseWarning",
84
+ afterRemoval: "no loud arm: a durable record still stored under its old session key is never found by its durable key (the job looks gone) — stated here " +
85
+ "instead of keeping a compatibility read",
86
+ },
87
+ {
88
+ id: "SS-M4",
89
+ oldForm: "refused",
90
+ introducedIn: "11.0.0",
91
+ releasedOn: "2026-10-10",
92
+ removableAfter: "2026-11-09",
93
+ rewrites: "workers: a 6.x row (env.REMOTE_EXEC + lane keys, runtime.sessionBackend / memoryBackend / db, coords.tidb*, secretRefs.tidbPasswordEnv, store " +
94
+ "keys in env) is rewritten into runtime.execution + runtime.store; a row the step would have to guess for, or that still does not read after the " +
95
+ "rewrite, is refused and kept verbatim (readWorkersRows holds it on read). Runs last: the rewritten row is judged after every other step's " +
96
+ "rewrite (SS-M7's fragment strip included)",
97
+ removal: "the next major after the window: delete the SS-M4 step; the 6.x tombstone fields of WorkerSpec and the reserved-env signpost stay — their SS-M4 clause goes",
98
+ afterRemoval: "the 6.x tombstone fields of WorkerSpec and the reserved-env refusal (permanent): a residual 6.x row is held by readWorkersRows, never rewritten",
99
+ },
100
+ {
101
+ id: "SS-M5",
102
+ oldForm: "refused",
103
+ introducedIn: "11.0.0",
104
+ releasedOn: "2026-10-10",
105
+ removableAfter: "2026-11-09",
106
+ rewrites: "models: an entry carrying both sealedApiKey and apiKeyEnv loses apiKeyEnv (the sealed key won — the effective reading before 11.0.0)",
107
+ removal: "the next major after the window: delete the SS-M5 step; the ModelEntry one-key rule loses its SS-M5 clause",
108
+ afterRemoval: "the ModelEntry one-key rule (permanent): a residual dual-key entry is dropped on read (`entry-dropped`) and refused on write",
109
+ },
110
+ {
111
+ id: "SS-M6",
112
+ oldForm: "refused",
113
+ introducedIn: "11.0.0",
114
+ releasedOn: "2026-10-10",
115
+ removableAfter: "2026-11-09",
116
+ rewrites: "models / rosters: atModelAllowlist → atMention by shape — [] → {mode:\"all\"}, a non-empty list → {mode:\"only\",models}; an absent key stays " +
117
+ "absent (the global default is all, a roster inherits); a document carrying both keys is refused (the step does not pick a winner)",
118
+ removal: "the next major after the window: delete the SS-M6 step; the atModelAllowlist tombstone fields stay — their SS-M6 clause goes",
119
+ afterRemoval: "the atModelAllowlist tombstone fields (permanent): a residual list is refused on read and write (models: the domain degrades to its default; " +
120
+ "rosters: the scope fails to read)",
121
+ },
122
+ {
123
+ id: "SS-M7",
124
+ oldForm: "refused",
125
+ introducedIn: "11.0.0",
126
+ releasedOn: "2026-10-10",
127
+ removableAfter: "2026-11-09",
128
+ rewrites: "every NON_SECRET_URL seat in a domain document (derived from the domain schemas, not listed by hand — today models.models[].baseUrl, " +
129
+ "mcp.servers[].transport.url, execution.sessionMirror.engineUrl, workers.workers[].coords.gitApiBaseUrl): `#` and everything after it are removed " +
130
+ "(a fragment never goes on the wire)",
131
+ removal: "the next major after the window: delete the SS-M7 step and its schema walk; the NON_SECRET_URL fragment rule loses its SS-M7 clause",
132
+ afterRemoval: "the NON_SECRET_URL shape judge, which refuses a fragment (`#` included) on read and write",
133
+ },
134
+ {
135
+ id: "SS-M9",
136
+ oldForm: "refused",
137
+ introducedIn: "11.0.0",
138
+ releasedOn: "2026-10-10",
139
+ removableAfter: "2026-11-09",
140
+ rewrites: "models: the structural `default` key → `roles.default = {model}` by what the default model was before 11.0.0 (the server's default " +
141
+ "resolution read `default` first, and the main task ran it): `roles.default` absent ⇒ moved there; the same name already there ⇒ " +
142
+ "moved (the duplicate goes); a different `roles.default` ⇒ that one is dropped as shadowed and `default` moves in; a `default` that is " +
143
+ "not a non-empty string is refused (it does not read today either)",
144
+ removal: "the next major after the window: delete the SS-M9 step; the models.default tombstone field stays — its SS-M9 clause goes",
145
+ afterRemoval: "the models.default tombstone field (permanent): a residual key is refused on read and write (the models domain degrades to its default)",
146
+ },
147
+ {
148
+ // 一条 wire 位的删码登记,不是数据迁移(id 在 WIRE_RETIREMENT_IDS,不在收据闭集)。
149
+ id: "SS-W1",
150
+ oldForm: "not-stored",
151
+ introducedIn: "11.0.0",
152
+ releasedOn: "2026-10-10",
153
+ removableAfter: "2026-11-09",
154
+ rewrites: "nothing stored: the flat engine-state dialect of the fleet wire — InstanceHealth.draining / InstanceHealth.drainingSince and " +
155
+ "InstanceAnnouncement.ready / InstanceAnnouncement.draining / InstanceAnnouncement.restart / InstanceAnnouncement.generation / " +
156
+ "InstanceAnnouncement.leaseState / InstanceAnnouncement.leaseUntil (and the same flat keys /health passes through untyped) — stays on " +
157
+ "both shapes for its contraction window: engines keep sending it for readers that predate the engine signal, and no reader in this " +
158
+ "package reads it (the engine's state is read only from EngineSignal under ENGINE_SIGNAL_KEY)",
159
+ removal: "the next major after the window: delete the flat engine-state dialect from InstanceHealth and InstanceAnnouncement and this row; " +
160
+ "engines stop sending it in the same wave",
161
+ afterRemoval: "a body that still carries the flat keys is read exactly as today: the heartbeat shape strips them, /health passes them through " +
162
+ "untyped, and no judge reads them — an engine that does not carry EngineSignal is no-engine-signal, never routable",
163
+ },
164
+ ];
165
+ /**
166
+ * 迁移一组存储文档(见文件头「入口」)。纯函数;没改的文档原样返回;**不抬任何版本号**(结果里没有版本 —— 那是调用方的)。
167
+ * 重跑恒等:第二次跑零改动,只剩第一次就 `refused` 的那几张收据(那些坐标原样留着,读侧照旧拒)。
168
+ */
169
+ export function migrateStoredDocuments(docs, opts) {
170
+ if (!isPlainObject(docs))
171
+ throw new TypeError("migrateStoredDocuments: `docs` must be a { domain: document } object");
172
+ switch (opts.coverage) {
173
+ case "scope":
174
+ case "partial":
175
+ break;
176
+ default: {
177
+ const unhandled = opts.coverage;
178
+ throw new TypeError(`migrateStoredDocuments: coverage must be "scope" or "partial" (got ${JSON.stringify(unhandled)})`);
179
+ }
180
+ }
181
+ const out = { ...docs };
182
+ const receipts = [];
183
+ // 步序的律:纯改写步在前,拿新 schema 终判改写结果的步(SS-M4)最后 —— 它判的是**全部**改写之后的行,别的步能修好的形(例:SS-M7 剥掉的
184
+ // fragment)不会先被它拒掉(否则一跑 refused、二跑才过:既不是一次完成,也不幂等)。
185
+ stepRuntimeDomain(out, opts.coverage, receipts);
186
+ stepHostsBounds(out, receipts);
187
+ stepModelKeyOneOf(out, receipts);
188
+ stepAtMention(out, receipts);
189
+ stepModelsDefault(out, receipts);
190
+ stepUrlFragments(out, receipts);
191
+ stepWorkers6x(out, receipts);
192
+ return { docs: out, receipts };
193
+ }
194
+ /**
195
+ * 迁移一份冻结的 effective(见文件头「入口」):域键过与 {@link migrateStoredDocuments} 同一组步(coverage `"scope"` —— effective 里每个域
196
+ * 都物化了),其余键(`version` / `updatedAt` / wire 上的 `skills` 清单形 …)原样。键序保持;被移除的retired 域不在;effective 没改 ⇒ 同一引用。
197
+ * 不是对象 ⇒ 原样返回(判形是读者的事)。
198
+ */
199
+ export function migrateEffectiveSnapshot(effective) {
200
+ if (!isPlainObject(effective))
201
+ return { effective, receipts: [] };
202
+ const docs = {};
203
+ for (const [k, v] of Object.entries(effective))
204
+ if (isDomainName(k) || isRetiredDomainName(k))
205
+ docs[k] = v;
206
+ const m = migrateStoredDocuments(docs, { coverage: "scope" });
207
+ const changed = Object.keys(docs).some((k) => !Object.prototype.hasOwnProperty.call(m.docs, k) || m.docs[k] !== docs[k]) || Object.keys(m.docs).some((k) => !Object.prototype.hasOwnProperty.call(docs, k));
208
+ if (!changed)
209
+ return { effective, receipts: m.receipts };
210
+ const next = {};
211
+ for (const [k, v] of Object.entries(effective)) {
212
+ if (!(isDomainName(k) || isRetiredDomainName(k)))
213
+ next[k] = v;
214
+ else if (Object.prototype.hasOwnProperty.call(m.docs, k))
215
+ next[k] = m.docs[k];
216
+ }
217
+ for (const [k, v] of Object.entries(m.docs))
218
+ if (!Object.prototype.hasOwnProperty.call(next, k))
219
+ next[k] = v;
220
+ return { effective: next, receipts: m.receipts };
221
+ }
222
+ function isPlainObject(v) {
223
+ if (v === null || typeof v !== "object" || Array.isArray(v))
224
+ return false;
225
+ const proto = Object.getPrototypeOf(v);
226
+ return proto === Object.prototype || proto === null;
227
+ }
228
+ const has = (o, k) => isPlainObject(o) && Object.prototype.hasOwnProperty.call(o, k);
229
+ const without = (o, k) => {
230
+ const { [k]: _gone, ...rest } = o;
231
+ return rest;
232
+ };
233
+ // 收据的三只铸点。坐标里的键段是用户写的(未知键名、zod issue 路径里的 Record 键),完全可能就是一条粘错位置的凭据 ⇒ 与读侧告警坐标
234
+ // 走同一道出门(`outgoingPath`,codex r1 F1);拒因句过 `redactSecrets`。收据从不带值。
235
+ function receipt(migration, document, path, appliedAs) {
236
+ return { migration, document, path: outgoingPath(path), appliedAs };
237
+ }
238
+ function moved(migration, document, path, to) {
239
+ return { migration, document, path: outgoingPath(path), appliedAs: "moved", to: { document: to.document, path: outgoingPath(to.path) } };
240
+ }
241
+ function refused(migration, document, path, reason) {
242
+ return { migration, document, path: outgoingPath(path), appliedAs: "refused", reason: redactSecrets(reason) };
243
+ }
244
+ // ── SS-M1:retired `runtime` 域 ────────────────────────────────────────────────────────────────────────────
245
+ /** 10.x 的 runtime 域 schema(迁移私有:判「这份 runtime 文档迁移前读不读得进」—— 读不进的文档今天就让整个 scope 读不出,没有生效读法可保)。 */
246
+ const RUNTIME_10X = z.object({
247
+ rateLimitPerMin: INTEGER.nonnegative().optional(),
248
+ maxTaskCostUsd: z.number().finite().nonnegative().optional(),
249
+ maxTaskTokens: INTEGER.nonnegative().optional(),
250
+ maxPrincipalCostUsd: z.number().finite().nonnegative().optional(),
251
+ costQuotaWindowSec: INTEGER.positive().optional(),
252
+ });
253
+ const GOVERNANCE_TRIO = ["autonomy", "commandPolicy", "approvalRequire"];
254
+ const LIMIT_KEYS = ["rateLimitPerMin", "maxTaskCostUsd", "maxTaskTokens", "maxPrincipalCostUsd", "costQuotaWindowSec"];
255
+ function stepRuntimeDomain(out, coverage, receipts) {
256
+ if (!Object.prototype.hasOwnProperty.call(out, "runtime"))
257
+ return;
258
+ const raw = out.runtime;
259
+ if (raw === undefined)
260
+ return;
261
+ const refuse = (reason) => void receipts.push(refused("SS-M1", "runtime", [], reason));
262
+ if (isPlainObject(raw) && Object.keys(raw).length === 0) {
263
+ delete out.runtime;
264
+ receipts.push(receipt("SS-M1", "runtime", [], "dropped-inert"));
265
+ return;
266
+ }
267
+ if (!isPlainObject(raw))
268
+ return refuse("the stored runtime document is not a JSON object — the scope does not read today either; fix it by hand");
269
+ if (coverage === "partial" && !(has(out, "governance") && has(out, "limits"))) {
270
+ return refuse("a runtime document migrates only together with its scope's governance and limits documents (coverage \"scope\") — on its own there is no telling whether governance was ever set");
271
+ }
272
+ const parsed = RUNTIME_10X.safeParse(raw);
273
+ if (!parsed.success)
274
+ return refuse("the stored runtime document does not read under the 10.x runtime schema — the scope does not read today either; fix it by hand");
275
+ const limitsRaw = out.limits;
276
+ if (limitsRaw !== undefined && !isPlainObject(limitsRaw))
277
+ return refuse("the scope's limits document is not a JSON object — the scope does not read today either; fix limits first");
278
+ const trio = GOVERNANCE_TRIO.filter((k) => has(raw, k) && raw[k] !== undefined);
279
+ if (trio.length > 0) {
280
+ // 迁移前的读法:governance 域**从未设过**(店里没有这份文档)⇒ 读侧从 runtime 抬升三闸;设过 ⇒ 设过的那份赢,runtime 上的三闸从未生效。
281
+ if (out.governance === undefined) {
282
+ out.governance = Object.fromEntries(trio.map((k) => [k, raw[k]]));
283
+ for (const k of trio)
284
+ receipts.push(moved("SS-M1", "runtime", [k], { document: "governance", path: [k] }));
285
+ }
286
+ else {
287
+ for (const k of trio)
288
+ receipts.push(receipt("SS-M1", "runtime", [k], "dropped-shadowed"));
289
+ }
290
+ }
291
+ const limitKeys = LIMIT_KEYS.filter((k) => parsed.data[k] !== undefined);
292
+ if (limitKeys.length > 0) {
293
+ // 迁移前的读法(消费方):`limits` 有这一键 ⇒ limits 赢;没有 ⇒ 读 runtime 上的值。
294
+ const limits = isPlainObject(limitsRaw) ? { ...limitsRaw } : {};
295
+ let added = false;
296
+ for (const k of limitKeys) {
297
+ if (Object.prototype.hasOwnProperty.call(limits, k))
298
+ receipts.push(receipt("SS-M1", "runtime", [k], "dropped-shadowed"));
299
+ else {
300
+ limits[k] = parsed.data[k];
301
+ added = true;
302
+ receipts.push(moved("SS-M1", "runtime", [k], { document: "limits", path: [k] }));
303
+ }
304
+ }
305
+ if (added)
306
+ out.limits = limits;
307
+ }
308
+ const handled = new Set([...trio, ...limitKeys]);
309
+ for (const k of Object.keys(raw))
310
+ if (!handled.has(k))
311
+ receipts.push(receipt("SS-M1", "runtime", [k], "dropped-inert")); // runtime schema 从未读过的键
312
+ delete out.runtime;
313
+ }
314
+ // ── SS-M2:hosts 超界 ───────────────────────────────────────────────────────────────────────────────────
315
+ function stepHostsBounds(out, receipts) {
316
+ const doc = out.hosts;
317
+ if (!isPlainObject(doc) || !Array.isArray(doc.hosts))
318
+ return;
319
+ const rows = doc.hosts;
320
+ let kept = [];
321
+ const dropped = [];
322
+ rows.forEach((row, index) => {
323
+ const name = isPlainObject(row) ? row.name : undefined;
324
+ if (typeof name === "string" && name.length > HOST_NAME_MAX)
325
+ dropped.push(index);
326
+ else
327
+ kept.push({ row, index });
328
+ });
329
+ if (kept.length > HOSTS_ARRAY_MAX) {
330
+ for (const { index } of kept.slice(HOSTS_ARRAY_MAX))
331
+ dropped.push(index);
332
+ kept = kept.slice(0, HOSTS_ARRAY_MAX);
333
+ }
334
+ if (dropped.length === 0)
335
+ return;
336
+ for (const index of dropped.sort((a, b) => a - b))
337
+ receipts.push(receipt("SS-M2", "hosts", ["hosts", index], "dropped-inert"));
338
+ out.hosts = { ...doc, hosts: kept.map((k) => k.row) };
339
+ }
340
+ // ── SS-M4:workers 6.x 行 ───────────────────────────────────────────────────────────────────────────────
341
+ /** 6.x 删除项:`[容器, 键]`。 */
342
+ const SIX_X_STORE_FIELDS = [
343
+ ["runtime", "sessionBackend"],
344
+ ["runtime", "memoryBackend"],
345
+ ["runtime", "db"],
346
+ ["coords", "tidbHost"],
347
+ ["coords", "tidbPort"],
348
+ ["coords", "tidbUser"],
349
+ ["secretRefs", "tidbPasswordEnv"],
350
+ ];
351
+ /** 引擎 `MEMORY_ENGINE` 的两族词(比较前 trim + 小写)。 */
352
+ const MEMORY_ENGINE_ON_WORDS = ["on", "true", "1", "yes", "enabled"];
353
+ const MEMORY_ENGINE_OFF_WORDS = ["off", "false", "0", "no", "disabled"];
354
+ /** 引擎 `MYSQL_PORT` 的缺省 —— 旧形没写端口时引擎连的就是它。 */
355
+ const MYSQL_DEFAULT_PORT = 3306;
356
+ const isLaneWord = (v) => typeof v === "string" && EXECUTION_LANE_WORDS.some((w) => w === v);
357
+ const isStoreWord = (v) => typeof v === "string" && STORE_BACKENDS.some((w) => w === v);
358
+ const isMemoryBackendWord = (v) => typeof v === "string" && MEMORY_ENGINE_BACKENDS.some((w) => w === v);
359
+ /** 拒因里只点名形如词的值(短、小写);其余一律不回显(值可能是粘错位置的凭据)。 */
360
+ const describeWord = (v) => (typeof v === "string" && /^[a-z0-9][a-z0-9-]{0,31}$/.test(v) ? `"${v}"` : "(value not shown)");
361
+ const UNCHANGED = { kind: "unchanged" };
362
+ /**
363
+ * 一行的 6.x 改写。规则(每个旧键恰好一种去处):车道 —— `env.REMOTE_EXEC` 是车道词 ⇒ `runtime.execution.lane`,同车道的 env 键 ⇒ `coords`、
364
+ * 同车道的 `secretEnv` 密钥 ⇒ `secretRefs`;别车道的键与 `REQUIRE_EXECUTION_LANE` ⇒ 丢(引擎只读被点名车道那一臂);enabled 且无车道 ⇒ 拒
365
+ * (不替运维猜车道)。店 —— `sessionBackend` ⇒ `store.session`(`tidb` ⇒ `mysql`);`memoryBackend` ⇒ 丢(从未到达引擎);`coords.tidb*` +
366
+ * `runtime.db` ⇒ `store.db`(缺端口 ⇒ 3306),`tidbPasswordEnv` ⇒ `dbPasswordEnv`;无 host ⇒ `store.backend = local`;env 里的
367
+ * `DB_BACKEND` / `MEMORY_ENGINE` / `MEMORY_ENGINE_BACKEND` ⇒ 搬进 `store`。表达不了 ⇒ 拒:值写错了栏、不认识的词、新旧两形并存、
368
+ * `DB_BACKEND=pg` 配 MySQL 族坐标。不是 6.x 形的行(没有任何旧键,该有的块都在)⇒ 不动 —— 它读不读得进是 `readWorkersRows` 的事。
369
+ */
370
+ function migrateWorkerRow(raw) {
371
+ const refuse = (field, reason) => ({ kind: "refused", field, reason });
372
+ const enabled = raw.enabled !== false;
373
+ const laneKeysOf = (rec) => (isPlainObject(rec) ? Object.keys(rec).filter(isLaneEnvKey) : []);
374
+ const storeKeysOf = (rec) => (isPlainObject(rec) ? STORE_ENV_KEYS.filter((k) => Object.hasOwn(rec, k)) : []);
375
+ const envLaneKeys = laneKeysOf(raw.env);
376
+ const secretLaneKeys = laneKeysOf(raw.secretEnv);
377
+ const envStoreKeys = storeKeysOf(raw.env);
378
+ const secretStoreKeys = storeKeysOf(raw.secretEnv);
379
+ const oldStoreFields = SIX_X_STORE_FIELDS.filter(([c, k]) => has(raw[c], k));
380
+ const hasExecution = has(raw.runtime, "execution");
381
+ const hasStore = has(raw.runtime, "store");
382
+ const laneTouched = envLaneKeys.length + secretLaneKeys.length > 0;
383
+ const storeTouched = oldStoreFields.length + envStoreKeys.length + secretStoreKeys.length > 0;
384
+ if (!laneTouched && !storeTouched)
385
+ return UNCHANGED; // 没有任何 6.x 键:不是本步的形
386
+ if (hasExecution && laneTouched) {
387
+ return refuse(["runtime", "execution"], `declares runtime.execution AND execution-lane keys in env / secretEnv (${[...envLaneKeys, ...secretLaneKeys].join(", ")}) — keep one form; the migration does not pick a winner`);
388
+ }
389
+ // 新形的店字段有两处:`runtime.store` 与 `secretRefs.dbPasswordEnv`。任一在场 ∧ 还有旧店字段 ⇒ 两形并存,不挑赢家。
390
+ const newStoreFields = [...(hasStore ? ["runtime.store"] : []), ...(has(raw.secretRefs, "dbPasswordEnv") ? ["secretRefs.dbPasswordEnv"] : [])];
391
+ if (newStoreFields.length > 0 && storeTouched) {
392
+ const old = [...oldStoreFields.map(([c, k]) => `${c}.${k}`), ...envStoreKeys.map((k) => `env.${k}`), ...secretStoreKeys.map((k) => `secretEnv.${k}`)];
393
+ return refuse(["runtime", "store"], `declares the 7.0.0 store fields (${newStoreFields.join(", ")}) AND 6.x store fields (${old.join(", ")}) — keep one form; the migration does not pick a winner`);
394
+ }
395
+ const secretStoreKey = secretStoreKeys[0];
396
+ if (secretStoreKey !== undefined)
397
+ return refuse(["secretEnv", secretStoreKey], `${secretStoreKey} is set from a host secret — the migration cannot read it; declare runtime.store by hand`);
398
+ if (has(raw.secretEnv, "REMOTE_EXEC"))
399
+ return refuse(["secretEnv", "REMOTE_EXEC"], "the execution lane is set from a host secret — the migration cannot read it; declare runtime.execution.lane by hand");
400
+ const needLane = !hasExecution && (enabled || laneTouched);
401
+ const needStore = !hasStore && (enabled || storeTouched);
402
+ const w = structuredClone(raw);
403
+ const runtime = isPlainObject(w.runtime) ? w.runtime : {};
404
+ w.runtime = runtime;
405
+ const env = isPlainObject(w.env) ? w.env : {};
406
+ const secretEnv = isPlainObject(w.secretEnv) ? w.secretEnv : {};
407
+ const coords = isPlainObject(w.coords) ? w.coords : {};
408
+ const secretRefs = isPlainObject(w.secretRefs) ? w.secretRefs : {};
409
+ const dropped = [];
410
+ const drop = (container, slot, key) => {
411
+ delete container[key];
412
+ dropped.push([slot, key]);
413
+ };
414
+ // ── 车道
415
+ if (needLane) {
416
+ const rx = env.REMOTE_EXEC;
417
+ if (rx === undefined || rx === "") {
418
+ if (enabled) {
419
+ return refuse(["env", "REMOTE_EXEC"], `an enabled worker names no execution lane (env.REMOTE_EXEC is unset) — it boots with no hands and still reports tasks completed. ` +
420
+ `The migration does not guess a lane: set runtime.execution.lane (${EXECUTION_LANE_WORDS.join(" | ")}) by hand, or disable the worker`);
421
+ }
422
+ for (const k of envLaneKeys)
423
+ drop(env, "env", k); // 停用且没点名车道 ⇒ 每个车道键都是惰性的
424
+ for (const k of secretLaneKeys)
425
+ drop(secretEnv, "secretEnv", k);
426
+ }
427
+ else {
428
+ if (!isLaneWord(rx)) {
429
+ return refuse(["env", "REMOTE_EXEC"], `env.REMOTE_EXEC ${describeWord(rx)} is not an execution lane (${EXECUTION_LANE_WORDS.join(" | ")}) — the engine refuses to start with it; set runtime.execution.lane by hand`);
430
+ }
431
+ const lane = rx;
432
+ const laneCoords = {};
433
+ const laneSecretRefs = {};
434
+ for (const k of envLaneKeys) {
435
+ if (k === "REMOTE_EXEC") {
436
+ delete env[k];
437
+ continue;
438
+ }
439
+ const owner = LANE_ENV_OWNER.get(k);
440
+ if (owner === undefined || owner.lane !== lane) {
441
+ drop(env, "env", k); // REQUIRE_EXECUTION_LANE(声明了车道就恒渲 true)与别车道的键(引擎只读被点名那一臂)
442
+ continue;
443
+ }
444
+ const v = env[k];
445
+ if (owner.secret) {
446
+ return refuse(["env", k], `${k} is a secret of lane "${lane}" but env carries it as a literal value — the migration does not move a value into a secret slot: put it in the host secret source and reference it from runtime.execution.secretRefs.${k}`);
447
+ }
448
+ if (typeof v !== "string")
449
+ return refuse(["env", k], `env.${k} is not a string`);
450
+ laneCoords[k] = v;
451
+ delete env[k];
452
+ }
453
+ for (const k of secretLaneKeys) {
454
+ const owner = LANE_ENV_OWNER.get(k);
455
+ if (owner === undefined || owner.lane !== lane) {
456
+ drop(secretEnv, "secretEnv", k);
457
+ continue;
458
+ }
459
+ const v = secretEnv[k];
460
+ if (!owner.secret) {
461
+ return refuse(["secretEnv", k], `${k} is a non-secret coordinate of lane "${lane}" but is set from a host secret — the migration cannot read the value; write it in runtime.execution.coords.${k} by hand`);
462
+ }
463
+ if (typeof v !== "string")
464
+ return refuse(["secretEnv", k], `secretEnv.${k} is not a string`);
465
+ laneSecretRefs[k] = v;
466
+ delete secretEnv[k];
467
+ }
468
+ runtime.execution = { lane, coords: laneCoords, secretRefs: laneSecretRefs };
469
+ }
470
+ }
471
+ // ── 店
472
+ if (needStore) {
473
+ if (Object.hasOwn(runtime, "memoryBackend"))
474
+ drop(runtime, "runtime", "memoryBackend"); // 从未到达引擎
475
+ const sessionRaw = runtime.sessionBackend;
476
+ const host = coords.tidbHost;
477
+ const hostSet = typeof host === "string" && host !== "";
478
+ const dbBackendRaw = env.DB_BACKEND;
479
+ const dbBackendSet = dbBackendRaw !== undefined && dbBackendRaw !== "";
480
+ const named = hostSet || dbBackendSet || sessionRaw === "tidb";
481
+ const sqlCoordFields = [
482
+ [runtime, "runtime", "db"],
483
+ [coords, "coords", "tidbHost"],
484
+ [coords, "coords", "tidbPort"],
485
+ [coords, "coords", "tidbUser"],
486
+ [secretRefs, "secretRefs", "tidbPasswordEnv"],
487
+ ];
488
+ if (!enabled && !named) {
489
+ // 停用、旧形没点名任何店 ⇒ 不替它发明一个;旧字段全是惰性的
490
+ if (Object.hasOwn(runtime, "sessionBackend"))
491
+ drop(runtime, "runtime", "sessionBackend");
492
+ for (const [c, slot, k] of sqlCoordFields)
493
+ if (Object.hasOwn(c, k))
494
+ drop(c, slot, k);
495
+ for (const k of STORE_ENV_KEYS)
496
+ if (Object.hasOwn(env, k))
497
+ drop(env, "env", k);
498
+ }
499
+ else {
500
+ let session;
501
+ if (sessionRaw === undefined)
502
+ session = "auto"; // 6.x 的缺省
503
+ else if (sessionRaw === "tidb")
504
+ session = "mysql";
505
+ else if (sessionRaw === "memory" || sessionRaw === "auto")
506
+ session = sessionRaw;
507
+ else
508
+ return refuse(["runtime", "sessionBackend"], `runtime.sessionBackend ${describeWord(sessionRaw)} is not a 6.x session backend (tidb | memory | auto)`);
509
+ delete runtime.sessionBackend;
510
+ let backend;
511
+ if (dbBackendSet) {
512
+ if (!isStoreWord(dbBackendRaw))
513
+ return refuse(["env", "DB_BACKEND"], `env.DB_BACKEND ${describeWord(dbBackendRaw)} is not a store backend (${STORE_BACKENDS.join(" | ")}) — the engine refuses to start with it`);
514
+ backend = dbBackendRaw;
515
+ }
516
+ else {
517
+ backend = hostSet || sessionRaw === "tidb" ? "mysql" : "local";
518
+ }
519
+ delete env.DB_BACKEND;
520
+ const sqlCoordsPresent = sqlCoordFields.filter(([c, , k]) => Object.hasOwn(c, k));
521
+ const store = { backend, session };
522
+ if (backend === "mysql") {
523
+ if (hostSet) {
524
+ const port = coords.tidbPort;
525
+ store.db = {
526
+ host,
527
+ port: port === undefined ? MYSQL_DEFAULT_PORT : port,
528
+ ...(Object.hasOwn(coords, "tidbUser") ? { user: coords.tidbUser } : {}),
529
+ ...(Object.hasOwn(runtime, "db") ? { database: runtime.db } : {}),
530
+ };
531
+ }
532
+ if (Object.hasOwn(secretRefs, "tidbPasswordEnv"))
533
+ secretRefs.dbPasswordEnv = secretRefs.tidbPasswordEnv;
534
+ for (const [c, , k] of sqlCoordsPresent)
535
+ delete c[k];
536
+ }
537
+ else if (backend === "pg") {
538
+ if (sqlCoordsPresent.length > 0) {
539
+ return refuse(["env", "DB_BACKEND"], `env.DB_BACKEND=pg but the SQL coordinates are the MySQL family (coords.tidb* / runtime.db / secretRefs.tidbPasswordEnv, rendered as MYSQL_*) — the migration does not move a MySQL server's coordinates onto pg; declare runtime.store by hand`);
540
+ }
541
+ }
542
+ else {
543
+ for (const [c, slot, k] of sqlCoordsPresent)
544
+ drop(c, slot, k); // 非 SQL 店:SQL 坐标是「声明不用」的惰性件
545
+ }
546
+ const memory = {};
547
+ const engineRaw = env.MEMORY_ENGINE;
548
+ if (engineRaw !== undefined) {
549
+ const word = typeof engineRaw === "string" ? engineRaw.trim().toLowerCase() : undefined;
550
+ if (word !== undefined && MEMORY_ENGINE_ON_WORDS.includes(word))
551
+ memory.engine = "on";
552
+ else if (word !== undefined && MEMORY_ENGINE_OFF_WORDS.includes(word))
553
+ memory.engine = "off";
554
+ else if (word !== "")
555
+ return refuse(["env", "MEMORY_ENGINE"], "env.MEMORY_ENGINE is not an on/off word — the engine refuses to start with it");
556
+ delete env.MEMORY_ENGINE;
557
+ }
558
+ const memoryBackendRaw = env.MEMORY_ENGINE_BACKEND;
559
+ if (memoryBackendRaw !== undefined) {
560
+ if (!isMemoryBackendWord(memoryBackendRaw)) {
561
+ return refuse(["env", "MEMORY_ENGINE_BACKEND"], `env.MEMORY_ENGINE_BACKEND ${describeWord(memoryBackendRaw)} is not a memory backend (${MEMORY_ENGINE_BACKENDS.join(" | ")}) — the engine refuses to start with it`);
562
+ }
563
+ memory.backend = memoryBackendRaw;
564
+ delete env.MEMORY_ENGINE_BACKEND;
565
+ }
566
+ if (Object.keys(memory).length > 0)
567
+ store.memory = memory;
568
+ runtime.store = store;
569
+ }
570
+ }
571
+ // 被搬空的 env / secretEnv 整键去掉(它们是可选字段;留一个 `{}` 只是噪声)
572
+ if (isPlainObject(w.env) && Object.keys(env).length === 0 && isPlainObject(raw.env) && Object.keys(raw.env).length > 0)
573
+ delete w.env;
574
+ if (isPlainObject(w.secretEnv) && Object.keys(secretEnv).length === 0 && isPlainObject(raw.secretEnv) && Object.keys(raw.secretEnv).length > 0)
575
+ delete w.secretEnv;
576
+ // 改写后的行仍读不进 7.x 形 ⇒ 不写回半成品:拒,原文留盘(读口 `readWorkersRows` 扣下它)。判据与拒句是读口那一只(`workerRowVerdict`)。
577
+ const verdict = workerRowVerdict(w, "migrated");
578
+ if (verdict !== undefined)
579
+ return refuse(verdict.field, verdict.reason);
580
+ return { kind: "rewritten", row: w, dropped };
581
+ }
582
+ function stepWorkers6x(out, receipts) {
583
+ const doc = out.workers;
584
+ if (!isPlainObject(doc) || !Array.isArray(doc.workers))
585
+ return;
586
+ const rows = doc.workers;
587
+ let next;
588
+ rows.forEach((row, index) => {
589
+ if (!isPlainObject(row) || typeof row.name !== "string")
590
+ return; // 认不出名字:整域读侧判
591
+ const r = migrateWorkerRow(row);
592
+ switch (r.kind) {
593
+ case "unchanged":
594
+ return;
595
+ case "refused":
596
+ receipts.push(refused("SS-M4", "workers", ["workers", index, ...r.field], r.reason));
597
+ return;
598
+ case "rewritten":
599
+ next ??= [...rows];
600
+ next[index] = r.row;
601
+ receipts.push(receipt("SS-M4", "workers", ["workers", index], "rewritten"));
602
+ for (const field of r.dropped)
603
+ receipts.push(receipt("SS-M4", "workers", ["workers", index, ...field], "dropped-inert"));
604
+ return;
605
+ default: {
606
+ const unhandled = r;
607
+ throw new Error(`SS-M4: unhandled row outcome ${JSON.stringify(unhandled)}`);
608
+ }
609
+ }
610
+ });
611
+ if (next !== undefined)
612
+ out.workers = { ...doc, workers: next };
613
+ }
614
+ // ── SS-M5:模型钥匙二选一 ──────────────────────────────────────────────────────────────────────────────
615
+ function stepModelKeyOneOf(out, receipts) {
616
+ const doc = out.models;
617
+ if (!isPlainObject(doc) || !Array.isArray(doc.models))
618
+ return;
619
+ const rows = doc.models;
620
+ let next;
621
+ rows.forEach((row, index) => {
622
+ if (!(has(row, "sealedApiKey") && has(row, "apiKeyEnv")))
623
+ return;
624
+ const r = row;
625
+ if (r.sealedApiKey === undefined || r.apiKeyEnv === undefined)
626
+ return;
627
+ next ??= [...rows];
628
+ next[index] = without(r, "apiKeyEnv");
629
+ receipts.push(receipt("SS-M5", "models", ["models", index, "apiKeyEnv"], "dropped-shadowed"));
630
+ });
631
+ if (next !== undefined)
632
+ out.models = { ...doc, models: next };
633
+ }
634
+ // ── SS-M6:atModelAllowlist → atMention ────────────────────────────────────────────────────────────────
635
+ /** 一个位上的 `atModelAllowlist` 按形改写;读不进的旧值(不是字符串数组)/ 两形并存 ⇒ 拒。返回改后的对象或 `undefined`(没改)。 */
636
+ function rewriteAtMention(holder, document, at, receipts) {
637
+ if (!Object.prototype.hasOwnProperty.call(holder, "atModelAllowlist"))
638
+ return undefined;
639
+ const list = holder.atModelAllowlist;
640
+ if (Object.prototype.hasOwnProperty.call(holder, "atMention")) {
641
+ receipts.push(refused("SS-M6", document, [...at, "atModelAllowlist"], "both atModelAllowlist and atMention are set — keep one; the migration does not pick a winner"));
642
+ return undefined;
643
+ }
644
+ if (!Array.isArray(list) || !list.every((n) => typeof n === "string")) {
645
+ receipts.push(refused("SS-M6", document, [...at, "atModelAllowlist"], "atModelAllowlist is not a list of model names — it does not read today either; fix it by hand"));
646
+ return undefined;
647
+ }
648
+ const atMention = list.length === 0 ? { mode: "all" } : { mode: "only", models: [...list] };
649
+ receipts.push(moved("SS-M6", document, [...at, "atModelAllowlist"], { document, path: [...at, "atMention"] }));
650
+ return { ...without(holder, "atModelAllowlist"), atMention };
651
+ }
652
+ function stepAtMention(out, receipts) {
653
+ if (isPlainObject(out.models)) {
654
+ const next = rewriteAtMention(out.models, "models", [], receipts);
655
+ if (next !== undefined)
656
+ out.models = next;
657
+ }
658
+ const rosters = out.rosters;
659
+ if (isPlainObject(rosters) && Array.isArray(rosters.rosters)) {
660
+ const rows = rosters.rosters;
661
+ let next;
662
+ rows.forEach((row, index) => {
663
+ if (!isPlainObject(row))
664
+ return;
665
+ const r = rewriteAtMention(row, "rosters", ["rosters", index], receipts);
666
+ if (r === undefined)
667
+ return;
668
+ next ??= [...rows];
669
+ next[index] = r;
670
+ });
671
+ if (next !== undefined)
672
+ out.rosters = { ...rosters, rosters: next };
673
+ }
674
+ }
675
+ // ── SS-M9:models.default → roles.default ──────────────────────────────────────────────────────────────
676
+ /** 迁移前「缺省模型是谁」的生效读法 = server 的缺省解析以结构键 `default` 为第一级,主任务真跑它(N-7 两答复现:`default` = A、`roles.default`
677
+ * = B 时主任务跑 A,core 的 `default` 角色解到 B)。所以并过去的是 `default` 的值:`roles.default` 缺席 ⇒ 搬入;同名 ⇒ 搬入(重复的那一份
678
+ * 消失);异名 ⇒ 原 `roles.default` 作「被压过」删掉、`default` 搬入。`default` 不是非空串 ⇒ 拒(今天它让整个 models 域读不进,不替运维猜)。 */
679
+ function stepModelsDefault(out, receipts) {
680
+ const doc = out.models;
681
+ if (!isPlainObject(doc) || !Object.prototype.hasOwnProperty.call(doc, "default"))
682
+ return;
683
+ const name = doc.default;
684
+ if (typeof name !== "string" || name === "") {
685
+ receipts.push(refused("SS-M9", "models", ["default"], "models.default is not a catalog name — it does not read today either; fix it by hand"));
686
+ return;
687
+ }
688
+ const roles = isPlainObject(doc.roles) ? doc.roles : {};
689
+ const prior = roles.default;
690
+ if (prior !== undefined && !(isPlainObject(prior) && prior.model === name))
691
+ receipts.push(receipt("SS-M9", "models", ["roles", "default"], "dropped-shadowed"));
692
+ receipts.push(moved("SS-M9", "models", ["default"], { document: "models", path: ["roles", "default"] }));
693
+ out.models = { ...without(doc, "default"), roles: { ...roles, default: { model: name } } };
694
+ }
695
+ // ── SS-M7:URL fragment ────────────────────────────────────────────────────────────────────────────────
696
+ /** 这只(已展开的)schema 是不是一个 `NON_SECRET_URL` 座位:就是它,或是「先归一、再交给它」的管道(`z.string().trim().pipe(NON_SECRET_URL)`)。 */
697
+ function isUrlSeat(s) {
698
+ return s === NON_SECRET_URL || (s instanceof z.ZodPipe && unwrapSchema(s.out) === NON_SECRET_URL);
699
+ }
700
+ /**
701
+ * 顺着域 schema 走文档,在每个 URL 座位上剥 fragment。座位由 schema 派生(不手抄座位表):对象逐键、数组逐元素、Record 逐值、
702
+ * 判别联合按判别值取那一支、普通联合只认「某一支是 URL 座位」的字符串值(对象值不进普通联合 —— 哪一支说了算猜不出,不猜)。
703
+ * 写时复制:没改 ⇒ 原引用。
704
+ */
705
+ function stripFragments(schema, value, path, onStrip, depth = 0) {
706
+ if (depth > 32)
707
+ return value;
708
+ const s = unwrapSchema(schema);
709
+ if (isUrlSeat(s)) {
710
+ if (typeof value !== "string")
711
+ return value;
712
+ const hash = value.indexOf("#");
713
+ if (hash < 0)
714
+ return value;
715
+ onStrip(path);
716
+ return value.slice(0, hash);
717
+ }
718
+ if (s instanceof z.ZodDiscriminatedUnion) {
719
+ if (!isPlainObject(value))
720
+ return value;
721
+ const disc = s._zod.def.discriminator;
722
+ for (const option of s.options) {
723
+ const o = unwrapSchema(option);
724
+ if (!(o instanceof z.ZodObject))
725
+ continue;
726
+ const lit = unwrapSchema(o.shape[disc]);
727
+ if (lit instanceof z.ZodLiteral && lit.values.has(value[disc]))
728
+ return stripFragments(o, value, path, onStrip, depth + 1);
729
+ }
730
+ return value;
731
+ }
732
+ if (s instanceof z.ZodUnion) {
733
+ if (typeof value !== "string")
734
+ return value;
735
+ for (const option of s.options)
736
+ if (isUrlSeat(unwrapSchema(option)))
737
+ return stripFragments(option, value, path, onStrip, depth + 1);
738
+ return value;
739
+ }
740
+ if (s instanceof z.ZodObject) {
741
+ if (!isPlainObject(value))
742
+ return value;
743
+ let next;
744
+ for (const [k, fieldSchema] of Object.entries(s.shape)) {
745
+ if (!Object.prototype.hasOwnProperty.call(value, k))
746
+ continue;
747
+ const v = stripFragments(fieldSchema, value[k], [...path, k], onStrip, depth + 1);
748
+ if (v !== value[k])
749
+ (next ??= { ...value })[k] = v;
750
+ }
751
+ return next ?? value;
752
+ }
753
+ if (s instanceof z.ZodArray) {
754
+ if (!Array.isArray(value))
755
+ return value;
756
+ let next;
757
+ value.forEach((el, i) => {
758
+ const v = stripFragments(s.element, el, [...path, i], onStrip, depth + 1);
759
+ if (v !== el)
760
+ (next ??= [...value])[i] = v;
761
+ });
762
+ return next ?? value;
763
+ }
764
+ if (s instanceof z.ZodRecord) {
765
+ if (!isPlainObject(value))
766
+ return value;
767
+ let next;
768
+ for (const [k, el] of Object.entries(value)) {
769
+ const v = stripFragments(s._zod.def.valueType, el, [...path, k], onStrip, depth + 1);
770
+ if (v !== el)
771
+ (next ??= { ...value })[k] = v;
772
+ }
773
+ return next ?? value;
774
+ }
775
+ return value;
776
+ }
777
+ /** 本包域 schema 里全部 URL 座位的坐标模式(派生;数组 / Record 位写 `*`)—— 迁移不用它,它给钉与说明看「今天派生出哪些座位」。 */
778
+ export function urlSeatPatterns() {
779
+ const out = [];
780
+ const walk = (schema, path, depth) => {
781
+ if (depth > 32)
782
+ return;
783
+ const s = unwrapSchema(schema);
784
+ if (isUrlSeat(s))
785
+ return void out.push(path.join("."));
786
+ if (s instanceof z.ZodDiscriminatedUnion) {
787
+ const disc = s._zod.def.discriminator;
788
+ for (const option of s.options) {
789
+ const o = unwrapSchema(option);
790
+ if (!(o instanceof z.ZodObject))
791
+ continue;
792
+ const lit = unwrapSchema(o.shape[disc]);
793
+ const word = lit instanceof z.ZodLiteral ? [...lit.values].join("|") : "?";
794
+ walk(o, [...path.slice(0, -1), `${path[path.length - 1] ?? ""}{${disc}=${word}}`], depth + 1);
795
+ }
796
+ return;
797
+ }
798
+ if (s instanceof z.ZodUnion) {
799
+ if (s.options.some((o) => isUrlSeat(unwrapSchema(o))))
800
+ out.push(path.join("."));
801
+ return;
802
+ }
803
+ if (s instanceof z.ZodObject) {
804
+ for (const [k, f] of Object.entries(s.shape))
805
+ walk(f, [...path, k], depth + 1);
806
+ return;
807
+ }
808
+ if (s instanceof z.ZodArray)
809
+ return walk(s.element, [...path, "*"], depth + 1);
810
+ if (s instanceof z.ZodRecord)
811
+ return walk(s._zod.def.valueType, [...path, "*"], depth + 1);
812
+ };
813
+ for (const [d, schema] of Object.entries(DOMAIN_SCHEMAS))
814
+ walk(schema, [d], 0);
815
+ return out;
816
+ }
817
+ function stepUrlFragments(out, receipts) {
818
+ for (const d of Object.keys(out)) {
819
+ if (!isDomainName(d))
820
+ continue;
821
+ const doc = out[d];
822
+ if (doc === undefined)
823
+ continue;
824
+ const next = stripFragments(DOMAIN_SCHEMAS[d], doc, [], (path) => receipts.push(receipt("SS-M7", d, path, "rewritten")));
825
+ if (next !== doc)
826
+ out[d] = next;
827
+ }
828
+ }