@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
package/dist/types.js CHANGED
@@ -1,26 +1,34 @@
1
1
  /**
2
- * Config schema — the single source of truth for what the config center manages.
2
+ * Config schema — the single source of truth for what web-admin manages.
3
3
  *
4
4
  * Organized into DOMAINS (one top-level menu each). The UI, the store, and the service-facing API all
5
5
  * speak these types. Use zod both to validate UI input AND to derive the TS types (one definition).
6
6
  *
7
7
  * Detail level is deliberate (the user asked for "详略设计"): `models` is the MVP — fully specified —
8
- * while `scenarios` / `runtime` carry a real-but-skeletal schema to be fleshed out in later phases
9
- * (see CHANGELOG.md; `integrations`/`access` were deleted in 0.6.0, EXPERT-REDESIGN §9). Adding a
8
+ * while `scenarios` carries a real-but-skeletal schema to be fleshed out in later phases
9
+ * (see CHANGELOG.md; `integrations`/`access` were deleted in 0.6.0, EXPERT-REDESIGN §9; `runtime` was retired in 11.0.0 —
10
+ * see {@link RETIRED_DOMAINS}). Adding a
10
11
  * field is: extend the zod schema here → the form + the effective-config API pick it up.
11
12
  *
12
- * ⚠️ SECURITY BOUNDARY (see the service-integration design note): the config center stores **non-secret**
13
+ * ⚠️ SECURITY BOUNDARY (see the service-integration design note): web-admin stores **non-secret**
13
14
  * config only. A model's API key / a backend token is NEVER stored here as a value — we store the NAME
14
15
  * of the env var that holds it (`apiKeyEnv`, `tokenEnv`), and the agent-service reads the real secret
15
16
  * from its own environment. So a leak of this DB never leaks a credential. This is **enforced** by the
16
17
  * `ENV_NAME` schema (regex) on every `*Env` field — a pasted secret value is rejected, not just discouraged.
17
18
  */
18
19
  import { z } from "zod";
20
+ import { INTEGER } from "./integer.js";
19
21
  import { MAX_EXTRA_BODY_DEPTH } from "./hash.js"; // reconcile the extraBody depth cap with the hash cap (R18)
20
22
  import { DispatchPolicy, FLEET_GENERATION_ENV, FLEET_SCOPE_ENV } from "./fleet.js"; // WorkerSpec.fleet.dispatch (scheduling framework D3)
21
- import { carriesUserinfo, percentLayers } from "./secret-scan.js"; // package-private normalization + the userinfo judgement (not in the barrel)
22
- import { EXECUTION_LANES, EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, SESSION_BACKENDS, STORE_BACKENDS } from "./execution-lanes.js"; // 7.0.0 M-R2: the lane / store vocabulary (one exported table)
23
- import { LANE_ENV_OWNER, MIGRATION_SIGNPOST, isMigratableEnvKey, laneEnvSeat, laneRowOf } from "./execution-lane-index.js"; // package-private indexes derived from that table (not in the barrel)
23
+ import { isLoopbackAuthHost } from "./loopback.js";
24
+ import { httpUrlFlaws, httpUrlRule } from "./url-shape.js"; // 11.0.0: THE URL shape judge // 11.0.0: the one narrow「本机」predicate (auth relaxation)
25
+ import { secretScanForms } from "./secret-scan.js"; // package-private normalization — the one set of forms both scanners read (not in the barrel)
26
+ import { WEB_ADMIN_ENV } from "./web-admin-env.js"; // the three connection keys the config renderer writes are reserved worker env
27
+ import { retiredEnvRefusal } from "./retired-envs.js"; // the worker env write gate asks the retired-env tombstone table too (one table, one refusal sentence)
28
+ import { Sha256Digest } from "./sha256.js"; // the one `sha256:<hex>` digest form
29
+ import { EXECUTION_LANES, EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, SESSION_BACKENDS, STORE_BACKENDS, retiredLaneWordRefusal } from "./execution-lanes.js"; // 7.0.0 M-R2: the lane / store vocabulary (one exported table)
30
+ import { LANE_ENV_OWNER, isMigratableEnvKey, laneEnvSeat, laneRowOf } from "./execution-lane-index.js"; // package-private indexes derived from that table (not in the barrel)
31
+ import { migrationSignpost, retiredFormSentence, retiredKey } from "./retired-form.js"; // 11.0.0: the ONE retired-form sentence + key tombstone (package-private)
24
32
  // COARSE, best-effort literal-secret SHAPE detector (NOT exhaustive — the real boundary is the env-NAME schema
25
33
  // on structured fields). Rejected in free-form value fields (worker `env`, model `extraBody`), URL components,
26
34
  // AND as an env-NAME value (an AWS key id is a valid env-NAME charset yet is a secret VALUE, not a NAME). Case-
@@ -30,9 +38,8 @@ import { LANE_ENV_OWNER, MIGRATION_SIGNPOST, isMigratableEnvKey, laneEnvSeat, la
30
38
  // 0.1.32: this is the root fix for the public-key false-positive — replaces the 0.1.31 name-based exemption,
31
39
  // which review flagged as a bypass (a private key misnamed `*_PUBKEY` skipped scanning entirely).
32
40
  /** A private-key PEM header (`-----BEGIN [RSA |EC |OPENSSH |ENCRYPTED ]PRIVATE KEY`), unanchored — the ONE definition
33
- * of that shape, read ANYWHERE in a string by BOTH judges: the validation detector {@link containsSecretToken}
34
- * (`scanForSecret`, on every percent layer — 10.0.0) and the display redactor `redactSecrets` (config-fns, on every display-side
35
- * normalized form). {@link SECRET_LITERAL_RE} is also composed from it (anchored, as one of its branches — the exported per-token
41
+ * of that shape, read ANYWHERE in a string by BOTH sides of the one scanner: the validation detector {@link containsSecretToken}
42
+ * (`scanForSecret`, 10.0.0) and the display redactor `redactSecrets` (config-fns) — on the same normalized forms since 11.0.0. {@link SECRET_LITERAL_RE} is also composed from it (anchored, as one of its branches — the exported per-token
36
43
  * literal shape). The header carries spaces, so the token-splitting passes can never isolate it; before 10.0.0 the validation side
37
44
  * therefore saw it only at the very start of a string, and a PEM behind a newline, a `key: ` prefix or any other text rode past
38
45
  * every free-text write port while the display side already withheld it (a `key=`-prefixed percent-encoded
@@ -63,6 +70,11 @@ function scanForSecret(s) {
63
70
  // the token; the JWT dot is handled above). R12. */
64
71
  if (/(?:^|[^A-Za-z0-9_-])(?:bearer|basic)[ \t]+\S{8,4096}/i.test(s))
65
72
  return true;
73
+ // The same scheme followed by an ENCODED space (`+` form-encoding, `%20` percent-encoding) and any token at all (S-797, 11.0.0):
74
+ // an encoded space is the mark of an encoded header VALUE (`note:Basic+dTpw`, `x-Bearer%20abc123`), not prose, so the 8-char floor
75
+ // the plain-space arm keeps against prose (`Basic auth`) does not apply. The boundary admits `-` / `_` (`x-Bearer%20…`). Bounded.
76
+ if (/(?:^|[^A-Za-z0-9])(?:bearer|basic)(?:\+|%20)[^\s+%]{1,4096}/i.test(s))
77
+ return true;
66
78
  // a JWT (3 dot-joined base64url segments, header starts `eyJ`) searched anywhere — so a mid-string one
67
79
  // survives the tokenizer's dot-split. ReDoS-SAFE: a token-boundary prefix `(?:^|[^A-Za-z0-9_-])` (one start
68
80
  // per word, so `"eyJ".repeat(n)` is a single anchored start = linear, not the quadratic an unanchored scan
@@ -81,18 +93,43 @@ function scanForSecret(s) {
81
93
  * fs path — so the worker dies at exec/boot, not at config edit. A NUL is never legitimate in a command / path
82
94
  * / host / image, so reject it on every exec-bound string field (mcp stdio + the whole remoteExec contract). R20. */
83
95
  export const hasNul = (s) => s.includes("\0");
84
- /** Does `s` contain a secret-shaped token? Scans every percent layer of it — the raw string AND up to three tolerant
85
- * percent-decoded layers (a secret can hide as `%73%6B-…`; the ONE definition of those layers is `percentLayers` in the
86
- * package-private `./secret-scan`) — so mcp args / execution coords / URL components all get uniform percent-decode coverage.
87
- * Tokenized + JWT-aware (see scanForSecret), plus a private-key PEM header read ANYWHERE in any layer ({@link PRIVATE_KEY_PEM_HEADER},
88
- * 10.0.0 — the display redactor reads the same regex). Shared by every free-text write port (NON_SECRET_URL, mcp stdio, execution coords, ssh login, worker env, …). Round-4/5/11. (6.3.0 moved
89
- * the decode loop out, unchanged in result — same layers, same bound, same verdict — and made it exception-free; the
90
- * display-side readings the redactor also judges are not this detector's.) We do NOT fail-closed on a bare `%` — that
91
- * would false-reject legit values like `100%` / `%s`. A secret glued to a malformed escape (`%ZZsk-`) is an accepted
92
- * coarse-heuristic gap (no consumer reconstructs it; the real guarantee is the env-NAME boundary on structured fields).
93
- * R11/R13/R14. */
96
+ /** The one rendering of a quoted sentence: `template[0] + show(operands[0]) + template[1] + … + template[n]`. `show` defaults to
97
+ * the value as written (that is how `message` is minted); a sanitizer / log boundary passes its own. */
98
+ export function renderQuotedSentence(p, show = String) {
99
+ return p.template.reduce((out, part, i) => (i === 0 ? part : `${out}${show(p.operands[i - 1])}${part}`), "");
100
+ }
101
+ const fixed = (text) => ({ fixedText: text });
102
+ /** Mint a custom issue's `message` + `params` from a tagged template (`buildQuotedIssue\`duplicate model "${name}"\``): every
103
+ * interpolation is an operand unless wrapped in {@link fixed} (then it is template text). The ONE minting point of quoted
104
+ * sentences in this file — a template literal written straight into `message` would put a value into a sentence with no record
105
+ * of where it is (a source gate pins that none is left). */
106
+ function buildQuotedIssue(strings, ...parts) {
107
+ const template = [strings[0]];
108
+ const operands = [];
109
+ parts.forEach((part, i) => {
110
+ if (typeof part === "object")
111
+ template[template.length - 1] += part.fixedText + strings[i + 1];
112
+ else {
113
+ operands.push(part);
114
+ template.push(strings[i + 1]);
115
+ }
116
+ });
117
+ const params = { template, operands };
118
+ return { message: renderQuotedSentence(params), params };
119
+ }
120
+ /** Does `s` contain a secret-shaped token? —— **the one scanner** (11.0.0, S-1274): the write side (this detector, behind every
121
+ * free-text write port — NON_SECRET_URL, mcp stdio, execution coords, ssh login, worker env, extraBody, …) and the display side
122
+ * (`redactSecrets`) read the SAME normalized forms ({@link secretScanForms} in the package-private `./secret-scan`: the raw string,
123
+ * up to three tolerant percent-decoded layers, each layer's boundary reading, each of those read the form-encoded way with `+` as a
124
+ * space) through the SAME shape table ({@link scanForSecret}). A string whose normalization did not finish (a fourth layer would
125
+ * still decode something) counts as secret-shaped: what cannot be normalized is not judged clean. Before 11.0.0 this detector read
126
+ * only the percent layers, so a header spelled only in a deeper layer or with `+` for its spaces passed write validation while the
127
+ * display side withheld it. We do NOT fail-closed on a bare `%` — that would false-reject legit values like `100%` / `%s`. A secret
128
+ * glued to a malformed escape (`%ZZsk-`) is an accepted coarse-heuristic gap (the real guarantee is the env-NAME boundary on
129
+ * structured fields). R11/R13/R14. */
94
130
  export function containsSecretToken(s) {
95
- return percentLayers(s).some((form) => scanForSecret(form));
131
+ const scan = secretScanForms(s);
132
+ return scan.unresolved || scan.forms.some((form) => scanForSecret(form));
96
133
  }
97
134
  /** Scan a URL's PATH + query + fragment (KEYS and values, decoded, tokenized) for a secret-shaped literal.
98
135
  * Userinfo is handled separately (NON_SECRET_URL bans it; the ssh username is scanned by `containsSecretToken`). Round-4/5. */
@@ -126,24 +163,57 @@ export function urlQueryFragmentHasSecret(u) {
126
163
  }
127
164
  return false;
128
165
  }
129
- // A URL that must NOT embed credentials in userinfo (`https://user:pass@host`) or smuggle a secret-shaped value
130
- // into the query/fragment — those would store a secret VALUE in the config + ship it on /effective (defeating
131
- // the env-NAME boundary). Must be an http(s) URL with a host (the scheme check rejects file://host/… /
132
- // javascript://host/… that DO carry a host). Round 2-5 hardening.
133
- export const NON_SECRET_URL = z
134
- .string()
135
- .url()
136
- .refine((s) => {
137
- try {
138
- const u = new URL(s);
139
- if (!(u.protocol === "http:" || u.protocol === "https:") || !u.host || carriesUserinfo(u))
140
- return false;
141
- return !urlQueryFragmentHasSecret(u);
142
- }
143
- catch {
144
- return true; // already passed .url(); unreachable in practice
166
+ /** The parts every {@link NON_SECRET_URL} seat forbids: userinfo (a credential in the URL — it would store a secret VALUE in the
167
+ * config and ship it on /effective, defeating the env-NAME boundary) and a fragment (never sent; anything a consumer appends
168
+ * after it is dropped with it — the engine and server refuse a model route root that carries one, so a stored one only fails
169
+ * later, on the worker). A query is a legitimate request parameter (`?api-version=…`) and stays. */
170
+ const NON_SECRET_URL_FORBIDS = ["userinfo", "fragment"];
171
+ /** The refusal sentence of one flaw at a {@link NON_SECRET_URL} seat: the judge's rule sentence, and — for the one flaw that stored data
172
+ * can carry from before 11.0.0 (a fragment) at a seat the one-time migration reaches — the signpost to the migration that strips
173
+ * it, joined the way the other signposted refusals join it (`<rule>; <signpost>`). Closed over the judge's flaw words: a new word is
174
+ * a compile error here. */
175
+ function nonSecretUrlSentence(flaw, reachedByMigration) {
176
+ switch (flaw) {
177
+ case "fragment":
178
+ return reachedByMigration ? `${httpUrlRule(flaw)}; ${migrationSignpost("SS-M7", "11.0.0")}` : httpUrlRule(flaw);
179
+ case "not-http-url":
180
+ case "userinfo":
181
+ case "query":
182
+ return httpUrlRule(flaw);
183
+ default: {
184
+ const unhandled = flaw;
185
+ throw new Error("nonSecretUrlSentence: unhandled flaw word", { cause: unhandled });
186
+ }
145
187
  }
146
- }, "must be an http(s) URL with a host and NO credentials in the userinfo/query/fragment — secrets go in the *Env (env-NAME) fields");
188
+ }
189
+ /** One judge, two seat kinds — the only difference is the fragment refusal's migration signpost. `reachedByMigration` = the seat sits in a
190
+ * stored domain document, which the one-time migration SS-M7 rewrites (it finds its seats by this schema's identity, so "carries the
191
+ * signpost" and "is rewritten by SS-M7" cannot drift apart). */
192
+ function createNonSecretUrl(reachedByMigration) {
193
+ return z.string().superRefine((s, ctx) => {
194
+ const flaws = httpUrlFlaws(s, NON_SECRET_URL_FORBIDS);
195
+ for (const flaw of flaws)
196
+ ctx.addIssue({ code: "custom", message: nonSecretUrlSentence(flaw, reachedByMigration), params: { flaw } });
197
+ if (flaws.length === 0 && urlQueryFragmentHasSecret(new URL(s))) {
198
+ ctx.addIssue({ code: "custom", message: "must not carry a secret-shaped value in its path or query — secrets go in the *Env (env-NAME) fields" });
199
+ }
200
+ });
201
+ }
202
+ /** A URL handed to `fetch` that carries no credential: judged by THE URL shape judge ({@link httpUrlFlaws}, `./url-shape`) — a
203
+ * hierarchical http(s) URL with neither userinfo nor a fragment (an empty `#` included, 11.0.0) — and with no secret-shaped value
204
+ * in its path or query ({@link urlQueryFragmentHasSecret}). One issue per flaw, its message the judge's rule sentence (no value
205
+ * echoed; a fragment's also names its migration, {@link nonSecretUrlSentence}); `params.flaw` carries the rule word.
206
+ *
207
+ * This is the seat of every URL in a stored DOMAIN document (models `baseUrl`, mcp `url`, `sessionMirror.engineUrl`, workers
208
+ * `gitApiBaseUrl`). A URL outside the domain documents — `remote-exec.json`'s endpoints, the web-admin credential envelope's
209
+ * `issuer` — uses {@link NON_SECRET_URL_NO_MIGRATION}.
210
+ *
211
+ * The value is kept EXACTLY as written: nothing here trims or normalizes it (zod's own `.url()` would hand back a rewritten string). */
212
+ export const NON_SECRET_URL = createNonSecretUrl(true);
213
+ /** {@link NON_SECRET_URL}'s judge for a seat no one-time migration reaches (not a domain document): identical rules and rule
214
+ * sentences, but a fragment's refusal carries no migration signpost — pointing at a migration that cannot rewrite the value would
215
+ * send the operator to a command that does nothing for it. */
216
+ export const NON_SECRET_URL_NO_MIGRATION = createNonSecretUrl(false);
147
217
  /** Validate a free-form `extraBody`: returns true = UNSAFE (reject). A GETTER-FREE, descriptor-based, bounded
148
218
  * walk that rejects — WITHOUT ever invoking a getter (a malicious getter could throw a secret-bearing message,
149
219
  * or return different values across the validate vs persist JSON.stringify calls) — anything that isn't plain
@@ -228,19 +298,19 @@ function vetFreeFormJson(v, field) {
228
298
  rawUnsafe = extraBodyUnsafe(v);
229
299
  }
230
300
  catch {
231
- return { ok: false, message: `${field} could not be validated (reflective access threw)` }; // R11
301
+ return { ok: false, ...buildQuotedIssue `${fixed(field)} could not be validated (reflective access threw)` }; // R11
232
302
  }
233
303
  if (rawUnsafe)
234
- return { ok: false, message: `${field}: a key/value is a secret shape, non-JSON (Infinity/function/Map), an accessor, cyclic, or too deep` };
304
+ return { ok: false, ...buildQuotedIssue `${fixed(field)}: a key/value is a secret shape, non-JSON (Infinity/function/Map), an accessor, cyclic, or too deep` };
235
305
  let clone;
236
306
  try {
237
307
  clone = JSON.parse(JSON.stringify(v)); // the ONLY serialization — its result is what we scan AND persist
238
308
  }
239
309
  catch {
240
- return { ok: false, message: `${field} is not JSON-serializable` };
310
+ return { ok: false, ...buildQuotedIssue `${fixed(field)} is not JSON-serializable` };
241
311
  }
242
312
  if (extraBodyUnsafe(clone))
243
- return { ok: false, message: `${field}: its serialized form carries a secret` };
313
+ return { ok: false, ...buildQuotedIssue `${fixed(field)}: its serialized form carries a secret` };
244
314
  return { ok: true, value: clone };
245
315
  }
246
316
  /** The transform of every **parse-transparent free-form** key (`z.unknown().optional().transform(createFreeFormTransform(field))`:
@@ -253,7 +323,7 @@ function createFreeFormTransform(field) {
253
323
  return undefined;
254
324
  const vetted = vetFreeFormJson(v, field);
255
325
  if (!vetted.ok) {
256
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: vetted.message });
326
+ ctx.addIssue({ code: "custom", message: vetted.message, params: vetted.params });
257
327
  return z.NEVER;
258
328
  }
259
329
  return vetted.value;
@@ -264,14 +334,14 @@ function createFreeFormTransform(field) {
264
334
  export const K8S_QUANTITY = z.string().regex(/^\d+(\.\d+)?(m|[kKMGTPE]i?)?$/, 'must be a k8s quantity like "250m", "512Mi", "1Gi"');
265
335
  /** A TCP port: 1–65535. ONE shared schema so every port field (workers.runtime.store.db.port, ssh.port, …) bounds
266
336
  * the SAME way — a port of 65536/0/-1 is config corruption, reject it at validation, not at connect time. R16. */
267
- export const TCP_PORT = z.number().int().positive().max(65535);
337
+ export const TCP_PORT = INTEGER.positive().max(65535);
268
338
  /** Per-1M-token price ($/Mtok). Maps to the service's MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE
269
339
  * (budget.ts buildPricing → per-principal cost gate). Lives on the models catalog (ModelEntry.cost) — the
270
340
  * single pricing source (worker.model, the old second consumer, was deleted in 0.7.0). A free model declares
271
341
  * 0s (core 1.26: missing cost ≠ cheapest). */
272
- // `.finite()` on every $/Mtok field: `z.number().nonnegative()` accepts Infinity (Infinity >= 0), which then
273
- // JSON.stringify's to `null` on write → a later read fails "expected number, received null" (fail-closed at the
274
- // WRONG time). `.int()` fields are already Infinity-safe (Infinity isn't an integer). Round-4.
342
+ // `.finite()` on every $/Mtok field states the rule HERE rather than leaning on whether the library's `number()` admits
343
+ // Infinity (zod 3's did): a non-finite number JSON.stringify's to `null` on write → a later read fails "expected number,
344
+ // received null" (fail-closed at the WRONG time). Integer fields ({@link INTEGER}) are Infinity-safe by their own rule. Round-4.
275
345
  export const ModelCost = z.object({
276
346
  input: z.number().finite().nonnegative(),
277
347
  output: z.number().finite().nonnegative(),
@@ -282,12 +352,12 @@ export const ModelCost = z.object({
282
352
  // Maps 1:1 onto the worker service's config.models / config.roles / @-model allowlist (core 1.24 role
283
353
  // map). `name` is the catalog key AND the `@name` handle; `id` is the gateway's model id.
284
354
  // ── sealed-box 密钥托管(0.10.5,产品拍板铁律修订:「不存 secret 值」精化为「不存**可解明文**」)──
285
- // center 可存"不可解密的密文":浏览器端用执行域公钥 libsodium sealed-box(crypto_box_seal,X25519+
286
- // XSalsa20-Poly1305)加密后提交,center 无私钥永远解不开;caps/config 原样下发,执行侧私钥解密注入 env。
287
- // 明文 key 绝不出浏览器 = center 服务端代码路径里绝不出现明文。
355
+ // web-admin 可存"不可解密的密文":浏览器端用执行域公钥 libsodium sealed-box(crypto_box_seal,X25519+
356
+ // XSalsa20-Poly1305)加密后提交,web-admin 无私钥永远解不开;caps/config 原样下发,执行侧私钥解密注入 env。
357
+ // 明文 key 绝不出浏览器 = web-admin 服务端代码路径里绝不出现明文。
288
358
  /** sealed-box 算法标识(v1 唯一取值)。z.literal 钉死:换算法=换值=显式契约演进,不许静默漂移。 */
289
359
  export const SEALED_BOX_ALG = "libsodium-sealedbox-x25519";
290
- /** base64 形状底线(标准字母表,可带 padding)。**只校验形状不校验内容**——center 是托管方不是消费方,
360
+ /** base64 形状底线(标准字母表,可带 padding)。**只校验形状不校验内容**——web-admin 是托管方不是消费方,
291
361
  * 解密/解不开是执行侧运行时的事;这里只挡"根本不是 base64"的写入事故。 */
292
362
  export const BASE64_SHAPE = /^[A-Za-z0-9+/]+={0,2}$/;
293
363
  /** 接入模型的 sealed-box 托管密文(默认模式「直接贴 key」的存储形态)。
@@ -295,19 +365,19 @@ export const BASE64_SHAPE = /^[A-Za-z0-9+/]+={0,2}$/;
295
365
  * 真实 API key ≤ 几百字节;`.max(8192)` 只防 blob 滥用,不是格式承诺。
296
366
  * - `publicKeyId`:加密时用的执行域公钥 id(指向 execution.publicKeys[].publicKeyId)——执行侧凭它选私钥;
297
367
  * 轮换后旧密文仍带旧 id,可解性由执行侧留存的旧私钥决定。
298
- * - **write-only 纪律(消费方约定)**:center 的 GET 面永不回 ciphertext 本体(只回 publicKeyId+设置
368
+ * - **write-only 纪律(消费方约定)**:web-admin 的 GET 面永不回 ciphertext 本体(只回 publicKeyId+设置
299
369
  * 时间);密文字段绝不进任何日志。schema 层不执法读面,这是 API 层的契约义务。
300
370
  * - **与 `apiKeyEnv` 的互斥语义**:见 ModelEntry.sealedApiKey JSDoc(两者都设 = sealed 优先)。 */
301
371
  export const SealedApiKey = z.object({
302
- /** sealed-box 密文,base64(形状校验 only——内容对 center 永远不透明)。 */
372
+ /** sealed-box 密文,base64(形状校验 only——内容对 web-admin 永远不透明)。 */
303
373
  ciphertext: z.string().min(1).max(8192).regex(BASE64_SHAPE, "ciphertext must be base64"),
304
374
  /** 加密所用执行域公钥的 id(execution.publicKeys 登记表的键)。 */
305
375
  publicKeyId: z.string().min(1).max(128),
306
376
  /** 算法标识,v1 恒为 {@link SEALED_BOX_ALG}。 */
307
377
  alg: z.literal(SEALED_BOX_ALG),
308
- /** 密文设置时刻(ISO)。center 写面 server 端盖章(projects.registeredAt 同款纪律);write-only
378
+ /** 密文设置时刻(ISO)。web-admin 写面 server 端盖章(projects.registeredAt 同款纪律);write-only
309
379
  * 的 masked GET 面靠它+publicKeyId 展示「已于 X 设置」——密文本体永不回读。optional:执行侧
310
- * 解密不依赖它,非 center 写面(本地 TOC 手写配置)缺省合法。 */
380
+ * 解密不依赖它,非 web-admin 写面(本地 TOC 手写配置)缺省合法。 */
311
381
  setAt: z.string().min(1).optional(),
312
382
  });
313
383
  /**
@@ -367,6 +437,10 @@ export const PARSE_TRANSPARENT_KEYS = [
367
437
  why: "它的形由同一条 override 的 `key` 决定(一个 key 一种形),执法点在读那个 key 的消费方;在这里写死形等于把所有可覆盖键的形表搬进本包。",
368
438
  },
369
439
  ];
440
+ /** A catalog model's price / capability tier — fuels core 1.25 `selectModel({ tier })` role resolution. ONE closed set for both
441
+ * places that name it (`ModelEntry.tier`, `RoleTarget.select.tier`). Not the tier-group ladder of {@link TierBindings}
442
+ * (`ultra` … `lite`), which binds model NAMES to rungs. */
443
+ export const ModelTier = z.enum(["cheap", "standard", "premium"]);
370
444
  export const ModelEntry = z.object({
371
445
  /** Catalog key + `@name` mention handle (stable, url/word-safe), e.g. "model-a". */
372
446
  name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
@@ -384,27 +458,26 @@ export const ModelEntry = z.object({
384
458
  * 专家模式(env-NAME 引用,web 面折叠进「高级」);默认模式见 {@link ModelEntry}.sealedApiKey。 */
385
459
  apiKeyEnv: ENV_NAME.optional(),
386
460
  /** sealed-box 托管密文(0.10.5 默认模式「直接贴 key」;形状见 {@link SealedApiKey})。
387
- * **互斥语义(两者都设 = sealed 优先)**:`sealedApiKey` 与 `apiKeyEnv` 语义互斥——执行侧注入 env 时
388
- * sealed 解密值**压过** apiKeyEnv 引用(用户从专家模式切回默认贴 key 后,残留的 env-NAME 不得抢线)。
389
- * 刻意不 schema 层拒双设(拒了=切模式必须先清旧字段,UI/迁移两头找病;优先级裁决比互斥禁令便宜)。
390
- * ABSENT = 未托管(纯 env-NAME 现状),零迁移。 */
461
+ * **二选一**(11.0.0):一个模型的钥匙只有一种写法 —— `sealedApiKey` 与 `apiKeyEnv` 同设 ⇒ 本条目被拒(读写同一只判据,见本
462
+ * schema 的 superRefine)。切换写法的写者负责清掉另一字段。11.0.0 之前存下的双设条目由一次性迁移 SS-M5 改写(删 `apiKeyEnv`
463
+ * = 当年的生效读法「sealed 赢」)。ABSENT = 未托管(纯 env-NAME 现状)。 */
391
464
  sealedApiKey: SealedApiKey.optional(),
392
465
  /** Price/capability tier — fuels core 1.25 `selectModel({ tier })` role resolution. */
393
- tier: z.enum(["cheap", "standard", "premium"]).default("standard"),
466
+ tier: ModelTier.default("standard"),
394
467
  reasoning: z.boolean().default(false),
395
468
  vision: z.boolean().default(false),
396
- contextWindow: z.number().int().positive().optional(),
397
- maxTokens: z.number().int().positive().optional(),
398
- /** Auto-compaction trigger threshold (tokens) for this model — center 显式下发时本地 FileConfigStore
469
+ contextWindow: INTEGER.positive().optional(),
470
+ maxTokens: INTEGER.positive().optional(),
471
+ /** Auto-compaction trigger threshold (tokens) for this model — web-admin 显式下发时本地 FileConfigStore
399
472
  * lane 必须原样保真(0.10.10:strip 导致 parse 期即丢)。消费方=壳/引擎的 compaction
400
473
  * 配置(safety-merge autoCompaction seam);ABSENT = 消费方自身默认,零迁移。 */
401
- autoCompactTokens: z.number().int().positive().optional(),
474
+ autoCompactTokens: INTEGER.positive().optional(),
402
475
  /** Chars-per-token estimate for local token estimation (may be fractional, e.g. 3.5;CJK-heavy 模型
403
476
  * 比英文低)。与 autoCompactTokens 同班车(0.10.10 [830]④a);ABSENT = 消费方自身默认,零迁移。 */
404
477
  charsPerToken: z.number().finite().positive().optional(),
405
478
  /** Per-model prompt-guidance lines(装配 behavior 槽的 per-model 缓解句)。形对齐 core 消费面
406
479
  * `model.promptGuidance?: string[]`(engine/llm types;prompt-assembly 战役起装配位固定于
407
- * volatile 尾前)。web/center models 域先行小切片(不等 pack 大架构)。Non-secret model
480
+ * volatile 尾前)。web/web-admin models 域先行小切片(不等 pack 大架构)。Non-secret model
408
481
  * metadata(cost/tier 同类);ABSENT = 无 per-model 句,零迁移。 */
409
482
  promptGuidance: z.array(z.string()).optional(),
410
483
  /** $/Mtok. A genuinely-free model still declares 0s (core 1.26: missing cost ≠ cheapest). */
@@ -430,12 +503,12 @@ export const ModelEntry = z.object({
430
503
  return undefined;
431
504
  const proto = typeof v === "object" && v !== null ? Object.getPrototypeOf(v) : undefined;
432
505
  if (typeof v !== "object" || v === null || Array.isArray(v) || (proto !== Object.prototype && proto !== null)) {
433
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: "extraBody must be a plain JSON object" });
506
+ ctx.addIssue({ code: "custom", message: "extraBody must be a plain JSON object" });
434
507
  return z.NEVER;
435
508
  }
436
509
  const vetted = vetFreeFormJson(v, "extraBody");
437
510
  if (!vetted.ok) {
438
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: vetted.message });
511
+ ctx.addIssue({ code: "custom", message: vetted.message, params: vetted.params });
439
512
  return z.NEVER;
440
513
  }
441
514
  return vetted.value;
@@ -481,7 +554,10 @@ export const ModelEntry = z.object({
481
554
  // suggested this; enforce it in the SCHEMA so a direct PUT / local TOC config can't ship a mis-paired model.
482
555
  // (provider:"anthropic" must set api explicitly; gateway/vllm keep the openai-completions default.) Round-4.
483
556
  if ((m.provider === "anthropic") !== (m.api === "anthropic-messages"))
484
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `provider "${m.provider}" + api "${m.api}" mismatch — provider:"anthropic" ⟺ api:"anthropic-messages"`, path: ["api"] });
557
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `provider "${m.provider}" + api "${m.api}" mismatch — provider:"anthropic" ⟺ api:"anthropic-messages"`, path: ["api"] });
558
+ // 钥匙二选一(11.0.0):同设 = 两个答案回答「这个模型用哪把钥匙」。此前「sealed 压过 env」只活在注里与消费方三处优先级臂上。
559
+ if (m.sealedApiKey !== undefined && m.apiKeyEnv !== undefined)
560
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `sealedApiKey and apiKeyEnv are both set — a model's key has ONE form: keep sealedApiKey (a pasted key) or apiKeyEnv (an env-NAME), not both; ${fixed(migrationSignpost("SS-M5", "11.0.0"))}`, path: ["apiKeyEnv"] });
485
561
  });
486
562
  // `verifier` (2026-06-11, SERVICE-RESPONSE-…-PHASE2): real consumer = core's verification gate
487
563
  // (runWithVerification/resumeWithVerification resolve the verifier sub-task model via this role). Fallback
@@ -498,11 +574,26 @@ export const ModelRole = z.enum(["default", "summarize", "subagent", "team", "sy
498
574
  /** A role resolves either to a model NAME, or by capability/price (core 1.25 `{ select }`). */
499
575
  export const RoleTarget = z.union([
500
576
  z.object({ model: z.string().min(1) }),
501
- z.object({ select: z.object({ tier: z.enum(["cheap", "standard", "premium"]).optional(), needs: z.array(z.enum(["vision", "reasoning"])).optional(), prefer: z.enum(["cheapest", "premium"]).optional() }) }),
577
+ z.object({ select: z.object({ tier: ModelTier.optional(), needs: z.array(z.enum(["vision", "reasoning"])).optional(), prefer: z.enum(["cheapest", "premium"]).optional() }) }),
502
578
  ]);
503
- // ── sema 档位(tiers)与档位组(tier groups)──(center 半场, core 1.265 档位车) ──────
504
- // core 侧接缝 = `RunnerDeps.tiers`(档位词→模型绑定的**单表**, catalog 单点增广 expandTiers);center 侧
505
- // 把「多套命名预配置」存成 tierGroups,下发时只解析 ACTIVE 那一套成单表(组切换=center 换表下发,零新
579
+ /** {@link AtMention} 的模式闭集:`all` = 每个启用的模型都可 `@`;`none` = 一个都不许;`only` = 只许 `models` 里点名的那几个。 */
580
+ export const AT_MENTION_MODES = ["all", "none", "only"];
581
+ /** 「`@` 能点名哪些模型」(11.0.0,取代 `atModelAllowlist`)。义由 `mode` 说,不由数组空不空说:旧形一个数组靠「缺席 / 空 / 非空」
582
+ * 三种在场性背三种义,「一个都不许」表达不了,三个消费方各写了一只绕行。`only` 的名单至少一个名(空名单的义就是 `none`,一个义一种写法)。
583
+ * 全局位(`models.atMention`)缺省 `{mode:"all"}`;roster 位(`rosters[].atMention`)缺席 = 继承全局。收窄到某台 worker 的模型集
584
+ * 用 {@link narrowAtMention}(`./resolve-roster`)—— 一只读法。 */
585
+ export const AtMention = z.discriminatedUnion("mode", [
586
+ z.object({ mode: z.literal("all") }),
587
+ z.object({ mode: z.literal("none") }),
588
+ z.object({ mode: z.literal("only"), models: z.array(z.string().min(1)).min(1) }),
589
+ ]);
590
+ /** `models.default` 的墓碑(11.0.0,N-7):缺省模型只有 `roles.default` 一位。 */
591
+ const MODELS_DEFAULT_RETIRED = { retiredIn: "11.0.0", now: "roles.default ({model:<catalog name>}) — the one default-model seat", migration: "SS-M9" };
592
+ /** `atModelAllowlist` 的墓碑(11.0.0):全局位与 roster 位同一行。 */
593
+ const AT_MODEL_ALLOWLIST_RETIRED = { retiredIn: "11.0.0", now: "atMention ({mode:\"all\"} | {mode:\"none\"} | {mode:\"only\",models:[…]})", migration: "SS-M6" };
594
+ // ── sema 档位(tiers)与档位组(tier groups)──(web-admin 半场, core 1.265 档位车) ──────
595
+ // core 侧接缝 = `RunnerDeps.tiers`(档位词→模型绑定的**单表**, catalog 单点增广 expandTiers);web-admin 侧
596
+ // 把「多套命名预配置」存成 tierGroups,下发时只解析 ACTIVE 那一套成单表(组切换=web-admin 换表下发,零新
506
597
  // 引擎机制)。与 `ModelEntry.tier`(cheap/standard/premium 价格分级, core 1.25 selectModel)是两回事——
507
598
  // 那是能力/价格元数据,这是用户面的模型预配置绑定;词表也不同(max/pro/flash/lite + ultra 预留)。
508
599
  /** 档位词→catalog 模型 `name` 的绑定。值=目录键(string 引用,不 enum——开放性照 execution
@@ -519,23 +610,25 @@ export const TierBindings = z.object({
519
610
  export const TierGroup = z.object({
520
611
  /** Group key (same charset as catalog names), e.g. "daily" / "heavy". */
521
612
  name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
522
- tiers: TierBindings.default({}),
613
+ tiers: TierBindings.prefault({}),
523
614
  notes: z.string().optional(),
524
615
  });
525
616
  export const ModelsConfig = z
526
617
  .object({
527
- models: z.array(ModelEntry).default([]),
528
- /** 结构级显式默认模型 = catalog `name`(目录键)。与 `roles.default`(角色词)是两回事——消费方
529
- * (server resolveDefaultModelName)以本键为第一优先级,先于 roles/档位绑定;
530
- * ABSENT = 沿既有 roles/档位 fallback 链,存量 doc 零迁移。悬空 ref 写入时 fail-loud(见下
531
- * superRefine);未经 schema 的本地/存量 lane 由消费方 warn 降级(与 activeTierGroup 双层姿势一致)。 */
532
- default: z.string().min(1).optional(),
533
- /** role → target. Unset roles fall back to `default` (core fallback chain). */
534
- roles: z.record(ModelRole, RoleTarget).default({}),
535
- /** Which model names an end user may `@`-mention. Empty/undefined → all enabled models. */
536
- atModelAllowlist: z.array(z.string()).optional(),
618
+ models: z.array(ModelEntry).prefault([]),
619
+ /** role → target. Unset roles fall back to `default` (core fallback chain). `roles.default` is THE default model — the one seat
620
+ * (11.0.0, N-7: the structural `models.default` key is retired; it and `roles.default` used to answer the same question twice,
621
+ * the main task ran the first and the core `default` role resolved the second). Which catalog entry is the default is
622
+ * `resolveCatalogDefaultModel` (`./default-model`; a `{select}` default is handed back as-is for the engine to resolve). A stored
623
+ * `default` key is moved here by the one-time migration SS-M9. */
624
+ roles: z.partialRecord(ModelRole, RoleTarget).prefault({}),
625
+ /** Which model names an end user may `@`-mention({@link AtMention});缺省 `{mode:"all"}`。 */
626
+ atMention: AtMention.prefault({ mode: "all" }),
627
+ /** retired 键墓碑(11.0.0):在场即拒,拒句点名一次性迁移 SS-M6。 */
628
+ atModelAllowlist: retiredKey("atModelAllowlist", AT_MODEL_ALLOWLIST_RETIRED),
629
+ default: retiredKey("models.default", MODELS_DEFAULT_RETIRED),
537
630
  /** 命名档位组预配置。只有 ACTIVE 组的绑定表会下发为引擎的 tiers 单表。 */
538
- tierGroups: z.array(TierGroup).default([]),
631
+ tierGroups: z.array(TierGroup).prefault([]),
539
632
  /** 当前生效的档位组 name。未设(或无组)→ 不下发 tiers,引擎档位层恒惰性。 */
540
633
  activeTierGroup: z.string().optional(),
541
634
  })
@@ -548,53 +641,50 @@ export const ModelsConfig = z
548
641
  const seen = new Set();
549
642
  cfg.models.forEach((m, i) => {
550
643
  if (seen.has(m.name))
551
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate model "${m.name}" — the catalog key (and @-handle) must be unique`, path: ["models", i, "name"] });
644
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate model "${m.name}" — the catalog key (and @-handle) must be unique`, path: ["models", i, "name"] });
552
645
  seen.add(m.name);
553
646
  });
554
647
  // Catalog self-references must resolve to a real model — a dangling global `roles.{role}.model` or an
555
- // `atModelAllowlist` name not in `models` ships a broken wire (the global /effective path uses eff.models
556
- // directly, with no roster filter). This lived ONLY in refIntegrityIssues (sema-registry) — but the schema
648
+ // `atMention.models` name not in `models` ships a broken wire (the global /effective path uses eff.models
649
+ // directly, with no roster filter). This lived ONLY in refIntegrityIssues (web-admin) — but the schema
557
650
  // is the only guard for a local TOC config / a direct PUT. Round-4 (reproduced).
558
651
  const names = new Set(cfg.models.map((m) => m.name));
559
652
  for (const [role, t] of Object.entries(cfg.roles ?? {})) {
560
653
  if (t && "model" in t && !names.has(t.model))
561
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `role "${role}" → model "${t.model}" is not in the catalog`, path: ["roles", role, "model"] });
654
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `role "${role}" → model "${t.model}" is not in the catalog`, path: ["roles", role, "model"] });
562
655
  }
563
- (cfg.atModelAllowlist ?? []).forEach((n, i) => {
564
- if (!names.has(n))
565
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `atModelAllowlist["${n}"] is not in the catalog`, path: ["atModelAllowlist", i] });
566
- });
567
- // 结构级 default 悬空检测(照上面 roles/allowlist 同款,写入时 fail-loud;消费方对未经 schema 的
568
- // 存量/本地 lane 仍 warn 降级——与 activeTierGroup 的「写面 fail-loud + 读面 graceful」双层姿势一致)。
569
- if (cfg.default !== undefined && !names.has(cfg.default)) {
570
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `default "${cfg.default}" is not in the catalog`, path: ["default"] });
656
+ if (cfg.atMention.mode === "only") {
657
+ cfg.atMention.models.forEach((n, i) => {
658
+ if (!names.has(n))
659
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `atMention.models["${n}"] is not in the catalog`, path: ["atMention", "models", i] });
660
+ });
571
661
  }
572
662
  // 档位组 ref 完整性(照上面 roles/allowlist 悬空检测同款):组名去重 + 绑定必须命中 catalog +
573
663
  // activeTierGroup 必须命中某组(悬空 active = 下发面从「换表」静默滑成「无表」,写入时 fail-loud)。
574
664
  const groupSeen = new Set();
575
665
  cfg.tierGroups.forEach((g, i) => {
576
666
  if (groupSeen.has(g.name))
577
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate tier group "${g.name}"`, path: ["tierGroups", i, "name"] });
667
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate tier group "${g.name}"`, path: ["tierGroups", i, "name"] });
578
668
  groupSeen.add(g.name);
579
669
  for (const [tier, model] of Object.entries(g.tiers)) {
580
670
  if (typeof model === "string" && !names.has(model))
581
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `tier group "${g.name}" ${tier} → model "${model}" is not in the catalog`, path: ["tierGroups", i, "tiers", tier] });
671
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `tier group "${g.name}" ${tier} → model "${model}" is not in the catalog`, path: ["tierGroups", i, "tiers", tier] });
582
672
  }
583
673
  });
584
674
  if (cfg.activeTierGroup !== undefined && !groupSeen.has(cfg.activeTierGroup)) {
585
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `activeTierGroup "${cfg.activeTierGroup}" is not a tier group name`, path: ["activeTierGroup"] });
675
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `activeTierGroup "${cfg.activeTierGroup}" is not a tier group name`, path: ["activeTierGroup"] });
586
676
  }
587
677
  });
588
678
  // ── Domain: rosters (per-worker model roster — the multi-roster design note) ───────────
589
679
  // A named VIEW over the global `models` catalog: which models a worker exposes + its role map + @-allowlist.
590
680
  // A worker references one by name (WorkerSpec.roster); GET /effective?worker=<name> resolves it. Models stay
591
681
  // single-sourced in the `models` domain — a roster only references them by name (cross-domain ref checked at
592
- // resolution/PUT, see the multi-roster design note §6). This is what lets one sema-registry serve heterogeneous
682
+ // resolution/PUT, see the multi-roster design note §6). This is what lets one web-admin serve heterogeneous
593
683
  // instances (OA vs review 各持异构模型) instead of a single global roster.
594
684
  export const RosterEntry = z.object({
595
685
  name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
596
686
  /** Catalog model names this roster exposes (must exist in models.models[].name). Empty = the full catalog. */
597
- models: z.array(z.string().min(1)).default([]),
687
+ models: z.array(z.string().min(1)).prefault([]),
598
688
  /**
599
689
  * 主模型角色标记(0.6.3, E3)——catalog model NAME。worker.model 废除后 service 从 roster
600
690
  * 解析 boot 主模型的归宿位:解析语义(resolve-roster)= primary 喂 `default`/`team`/`synthesize` 三角色、
@@ -605,18 +695,20 @@ export const RosterEntry = z.object({
605
695
  /** cheap 模型角色标记(同上;worker.model.cheapId 的迁移归宿,§3②)。 */
606
696
  cheapModel: z.string().min(1).optional(),
607
697
  /** Role map override; unset roles fall back to marker-derived roles, then the global models.roles. */
608
- roles: z.record(ModelRole, RoleTarget).optional(),
609
- /** @-mention allowlist override; unset → inherits the global models.atModelAllowlist. */
610
- atModelAllowlist: z.array(z.string()).optional(),
698
+ roles: z.partialRecord(ModelRole, RoleTarget).optional(),
699
+ /** `@` 点名覆盖({@link AtMention});缺席 = 继承全局 `models.atMention`。 */
700
+ atMention: AtMention.optional(),
701
+ /** retired 键墓碑(11.0.0):在场即拒,拒句点名一次性迁移 SS-M6。 */
702
+ atModelAllowlist: retiredKey("atModelAllowlist", AT_MODEL_ALLOWLIST_RETIRED),
611
703
  notes: z.string().optional(),
612
704
  });
613
705
  export const RostersConfig = z
614
- .object({ rosters: z.array(RosterEntry).default([]) })
706
+ .object({ rosters: z.array(RosterEntry).prefault([]) })
615
707
  .superRefine((cfg, ctx) => {
616
708
  const seen = new Set();
617
709
  cfg.rosters.forEach((r, i) => {
618
710
  if (seen.has(r.name))
619
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate roster "${r.name}"`, path: ["rosters", i, "name"] });
711
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate roster "${r.name}"`, path: ["rosters", i, "name"] });
620
712
  seen.add(r.name);
621
713
  // intra-roster consistency: a role target / an @-allowlist entry pinned to a model NAME must be one
622
714
  // this roster exposes (when it's a subset). Cross-domain (the name exists in the catalog) is checked at
@@ -627,39 +719,41 @@ export const RostersConfig = z
627
719
  // 0.6.3 markers: same subset-consistency line as roles/@-allowlist — a marker naming a model this
628
720
  // roster doesn't expose would derive a dangling role (dropped at resolution, but reject loudly at PUT).
629
721
  if (r.primaryModel && !allowed.has(r.primaryModel)) {
630
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `roster "${r.name}" primaryModel → "${r.primaryModel}" not in roster.models`, path: ["rosters", i, "primaryModel"] });
722
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `roster "${r.name}" primaryModel → "${r.primaryModel}" not in roster.models`, path: ["rosters", i, "primaryModel"] });
631
723
  }
632
724
  if (r.cheapModel && !allowed.has(r.cheapModel)) {
633
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `roster "${r.name}" cheapModel → "${r.cheapModel}" not in roster.models`, path: ["rosters", i, "cheapModel"] });
725
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `roster "${r.name}" cheapModel → "${r.cheapModel}" not in roster.models`, path: ["rosters", i, "cheapModel"] });
634
726
  }
635
727
  for (const [role, target] of Object.entries(r.roles ?? {})) {
636
728
  if (target && "model" in target && !allowed.has(target.model)) {
637
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `roster "${r.name}" role.${role} → "${target.model}" not in roster.models`, path: ["rosters", i, "roles", role] });
729
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `roster "${r.name}" role.${role} → "${target.model}" not in roster.models`, path: ["rosters", i, "roles", role] });
638
730
  }
639
731
  }
640
- (r.atModelAllowlist ?? []).forEach((name, j) => {
641
- if (!allowed.has(name))
642
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `roster "${r.name}" atModelAllowlist "${name}" not in roster.models`, path: ["rosters", i, "atModelAllowlist", j] });
643
- });
732
+ if (r.atMention?.mode === "only") {
733
+ r.atMention.models.forEach((name, j) => {
734
+ if (!allowed.has(name))
735
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `roster "${r.name}" atMention.models "${name}" not in roster.models`, path: ["rosters", i, "atMention", "models", j] });
736
+ });
737
+ }
644
738
  }
645
739
  });
646
740
  });
647
741
  // ── Domain: scenarios (场景组合配置化 — ASK-CENTER-2026-06-11-SCENARIOS, v1) ───────────────────────────
648
- // "组合即配置、能力钉死在部署": the center COMPOSES scenarios from the toolsets the deployment registers —
742
+ // "组合即配置、能力钉死在部署": web-admin COMPOSES scenarios from the toolsets the deployment registers —
649
743
  // it can never mint a new capability (a new toolset = a service code change, deliberately expensive). The
650
744
  // service overlays these onto its built-in scenario table at boot; SAME NAME OVERRIDES the built-in (产品拍板
651
745
  // ①, the worker logs `sema_registry_scenarios.shadowsBuiltin` as the audit hook). Invalid entries are
652
746
  // skipped+warned worker-side, never fail boot. restart-to-apply like skills/mcp (产品拍板 ③).
653
747
  // The old skeletal roles/tools fields are gone (never stored anywhere, zero consumers, no migration).
654
748
  export const ScenarioEntry = z.object({
655
- /** Scenario name — may equal a built-in name (then the center definition SHADOWS it). */
749
+ /** Scenario name — may equal a built-in name (then web-admin definition SHADOWS it). */
656
750
  name: z.string().regex(/^[a-z][a-z0-9-]{1,31}$/, "lowercase slug, 2-32 chars (a-z, 0-9, -)"),
657
751
  /** Toolset key from the SERVICE's registry (v1: "none" | "repo-readonly"). Deliberately a shaped string,
658
- * not an enum: the worker registry is the authority (unknown → skip+warn there), so the center never needs
752
+ * not an enum: the worker registry is the authority (unknown → skip+warn there), so web-admin never needs
659
753
  * a release just to NAME a toolset a newer service ships. */
660
754
  toolset: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/, "toolset key (lowercase slug)"),
661
755
  // ScenarioEntry.prompt 已废弃(产品裁定 2026-07-17,[ref]③a;第二评审独立同判「second
662
- // registry field would become an ambiguous precedence channel」):配置域 prompt 单入口=center
756
+ // registry field would become an ambiguous precedence channel」):配置域 prompt 单入口=web-admin
663
757
  // prompts binding(scenario 维度);引擎 API 层 TaskSpec.systemPrompt 保留(不同层,勿混)。
664
758
  // 现网无用户直删;z.object 非 strict ⇒ 存量数据带 prompt 被 strip,parse 不炸。
665
759
  // 落日(2.0.0):本条是**纯墓碑**(零代码,靠 strip 生效),没有要删的兼容码;它留在这里的唯一作用
@@ -669,12 +763,12 @@ export const ScenarioEntry = z.object({
669
763
  notes: z.string().optional(),
670
764
  });
671
765
  export const ScenarioConfig = z
672
- .object({ scenarios: z.array(ScenarioEntry).default([]) })
766
+ .object({ scenarios: z.array(ScenarioEntry).prefault([]) })
673
767
  .superRefine((cfg, ctx) => {
674
768
  const seen = new Set();
675
769
  cfg.scenarios.forEach((sc, i) => {
676
770
  if (seen.has(sc.name))
677
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate scenario "${sc.name}"`, path: ["scenarios", i, "name"] });
771
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate scenario "${sc.name}"`, path: ["scenarios", i, "name"] });
678
772
  seen.add(sc.name);
679
773
  });
680
774
  });
@@ -699,7 +793,7 @@ export const SystemEntry = z.object({
699
793
  name: z.string().regex(/^[a-z][a-z0-9-]{1,31}$/, "lowercase slug, 2-32 chars (a-z, 0-9, -)"),
700
794
  /** Workers this system may call — its token lands in each one's SERVICE_AUTH_TOKENS CSV (S2).
701
795
  * Cross-domain ref-checked against the workers domain. */
702
- callsWorkers: z.array(z.string().min(1)).default([]),
796
+ callsWorkers: z.array(z.string().min(1)).prefault([]),
703
797
  /** Where the orchestrator delivers this system's credential (the CALLER's secret). v1: a BARE secret name
704
798
  * in the orchestrator's OWN namespace (no "ns/name" — cross-namespace = an RBAC expansion, deferred until
705
799
  * a real need). Optional: a system without deliverTo gets a minted token visible only via out-of-band ops. */
@@ -722,21 +816,21 @@ export const SystemEntry = z.object({
722
816
  rotateNonce: z.string().max(64).optional(),
723
817
  /** Overlap window (seconds) during rotation before the OLD token is retired. Absent → orchestrator default
724
818
  * (service AI to confirm; S3). */
725
- rotateGraceSec: z.number().int().positive().optional(),
819
+ rotateGraceSec: INTEGER.positive().optional(),
726
820
  enabled: z.boolean().default(true),
727
821
  notes: z.string().optional(),
728
822
  });
729
823
  export const SystemsConfig = z
730
- .object({ systems: z.array(SystemEntry).default([]) })
824
+ .object({ systems: z.array(SystemEntry).prefault([]) })
731
825
  .superRefine((cfg, ctx) => {
732
826
  const seen = new Set();
733
827
  const targets = new Map(); // "secret\0key" -> index of first ENABLED claimant
734
828
  cfg.systems.forEach((sys, i) => {
735
829
  if (seen.has(sys.name))
736
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate system "${sys.name}"`, path: ["systems", i, "name"] });
830
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate system "${sys.name}"`, path: ["systems", i, "name"] });
737
831
  seen.add(sys.name);
738
832
  if (sys.deliverTo && DELIVER_DENYLIST.has(sys.deliverTo.k8sSecret)) {
739
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `deliverTo.k8sSecret "${sys.deliverTo.k8sSecret}" is a PLATFORM credential surface — refused (红线1)`, path: ["systems", i, "deliverTo", "k8sSecret"] });
833
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `deliverTo.k8sSecret "${sys.deliverTo.k8sSecret}" is a PLATFORM credential surface — refused (红线1)`, path: ["systems", i, "deliverTo", "k8sSecret"] });
740
834
  }
741
835
  // Cross-system deliverTo collision (S3 设计 §4.5, council DESIGN-4): two enabled systems writing the
742
836
  // same {secret,key} would silently overwrite each other (same SSA fieldManager). Save-time half; the
@@ -745,7 +839,7 @@ export const SystemsConfig = z
745
839
  const t = `${sys.deliverTo.k8sSecret}\0${sys.deliverTo.key}`;
746
840
  const prior = targets.get(t);
747
841
  if (prior !== undefined) {
748
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `deliverTo target "${sys.deliverTo.k8sSecret}/${sys.deliverTo.key}" already claimed by system "${cfg.systems[prior].name}" — one credential per delivery slot`, path: ["systems", i, "deliverTo"] });
842
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `deliverTo target "${sys.deliverTo.k8sSecret}/${sys.deliverTo.key}" already claimed by system "${cfg.systems[prior].name}" — one credential per delivery slot`, path: ["systems", i, "deliverTo"] });
749
843
  }
750
844
  else {
751
845
  targets.set(t, i);
@@ -773,30 +867,28 @@ export const SystemStatusEntry = z.object({
773
867
  reason: z.string().max(500).optional(),
774
868
  updatedAt: z.string(),
775
869
  });
776
- // ── Domain: runtime (限额残余 — API/CLI-only, EXPERT-REDESIGN §5) ─────────────────
777
- // 0.6.0 (runtime 域废除拆三刀): the governance trio (autonomy/commandPolicy/approvalRequire) moved OUT into
778
- // the new `governance` domain; the legacy-skeletal `approvalDeny`/`runStaleSec` (service never read them) are
779
- // DELETED outright. What remains = the fleet cost/rate LIMIT residue — kept API/CLI-only (the web renders NO
780
- // runtime surface; the "runtime" UI concept is abolished).
781
- // WIRE TRANSITION — **DONE, 0.7.1**: the 0.6.0-era 双写 (buildEffective mirroring the governance keys into the
782
- // runtime slot until the service switched) was WITHDRAWN once service 1.150.0 confirmed it reads `eff.governance`
783
- // (§5 收账). Today the runtime slot carries the limit residue ONLY, and `governance` is the sole wire position of
784
- // the 6 gates. The transitional `RuntimeWire` alias was deleted in #282 件3 (三消费仓 grep -w 零命中) — the READ
785
- // direction的 legacy lift (never-set governance domain ← old runtime doc, {@link GOVERNANCE_MIRROR_KEYS}) is a
786
- // different mechanism and stays.
787
- export const RuntimeConfig = z.object({
788
- // `.optional()` on every gate (settled discipline): ABSENT = "the center isn't managing this — keep your
789
- // env value"; present — INCLUDING an explicit 0 — overrides (publishable "gate OFF"). A default here would
790
- // silently override a deployment's env.
791
- rateLimitPerMin: z.number().int().nonnegative().optional(),
792
- // Fleet-level cost governance (SERVICE-RESPONSE-2026-06-11-PHASE2). All restart-to-apply, none secret.
793
- // Per-worker WorkerSpec.runtime.maxPrincipalCostUsd (injected env) outranks this fleet default — precedence
794
- // is the consumer's, documented in SERVICE-INTEGRATION.
795
- maxTaskCostUsd: z.number().finite().nonnegative().optional(),
796
- maxTaskTokens: z.number().int().nonnegative().optional(),
797
- maxPrincipalCostUsd: z.number().finite().nonnegative().optional(),
798
- costQuotaWindowSec: z.number().int().positive().optional(),
799
- });
870
+ // ── retired 域(11.0.0)─────────────────────────────────────────────────────────────────────────────
871
+ // `runtime` 域retired:0.6.0 起它只剩五个限额键,而 `limits`(0.19.0)全覆盖那五个键 —— 同一个限额两处可发布、判据不同、「limits 赢」
872
+ // 只活在消费方的注里;0.5 期的 runtime 文档还带着治理三闸,读侧靠一只「governance 从未设过就抬升」的臂接住。11.0.0 两件都收:
873
+ // 五个限额键只在 `limits`,治理三闸只在 `governance`;存量由一次性迁移 SS-M1 改写(`./one-time-migrations`)。
874
+ /** retired 域的**墓碑表**:域名 → retired版本 / 事实今天住哪 / 改写存量的一次性迁移。读存储文档的每个读者同判(`parseDomain` 拒写、`buildEffective`
875
+ * 向店要retired 域、`readEffectiveWire` 看 wire、`FileConfigStore` 与 `readBundle` 读 `config.d/<名>.json`):文档**带事实**(在场且不是 `{}`)⇒ 响亮拒
876
+ * (`RetiredDomainError`,句子点名迁移 id);缺席或 `{}`(没有可丢的事实)⇒ 不理会。墓碑是拒,不是读法 —— 它不把旧文档读成任何值。 */
877
+ export const RETIRED_DOMAINS = {
878
+ runtime: {
879
+ retiredIn: "11.0.0",
880
+ now: "governance (autonomy / commandPolicy / approvalRequire) and limits (rateLimitPerMin / maxTaskCostUsd / maxTaskTokens / maxPrincipalCostUsd / costQuotaWindowSec)",
881
+ migration: "SS-M1",
882
+ },
883
+ };
884
+ /** 是不是一个retired 域名(墓碑表成员)。 */
885
+ export function isRetiredDomainName(x) {
886
+ return typeof x === "string" && Object.prototype.hasOwnProperty.call(RETIRED_DOMAINS, x);
887
+ }
888
+ /** 一个retired 域的拒句(读写两面同一句)。 */
889
+ export function retiredDomainSentence(domain) {
890
+ return retiredFormSentence(`the "${domain}" config domain`, RETIRED_DOMAINS[domain]);
891
+ }
800
892
  // ── Domain: limits (cost/限流族的热更新发布位 — 0.19.0, 消费方 #322 批1) ──────
801
893
  // 为什么是**新顶层域**而不是往 `runtime` 里加键(消费方亲验,不是推测):`RuntimeConfig` 是 zod 默认的
802
894
  // `"strip"` 对象,`runtime` 文档里的未知键在两条腿上都被剥掉(实测 `{rateLimitPerMin:7,
@@ -805,10 +897,9 @@ export const RuntimeConfig = z.object({
805
897
  // 域表是闭集)读不到**:`config.d/limits.json` 根本不进 effective,且零告警。本域 = 那个缺口的源头修复
806
898
  // (CLI/桌面单机形正是热更新战役的主诉求形)。同族先例:governance 当年也是从 runtime 拆成独立顶层域。
807
899
  //
808
- // 与 `runtime` 的关系(刻意不合并,也刻意不删旧位):五个限额键(rateLimitPerMin / maxTaskCostUsd /
809
- // maxTaskTokens / maxPrincipalCostUsd / costQuotaWindowSec)两个位都可能出现。**优先级归消费方**并已
810
- // 成文(server:新位 `limits` 赢,旧位是过渡读法);本包两位都如实解析,不在契约层替消费方折叠——
811
- // 折叠会让「旧位有值、新位撤键」这类过渡态在两个消费者之间产生两种答案。
900
+ // 五个限额键(rateLimitPerMin / maxTaskCostUsd / maxTaskTokens / maxPrincipalCostUsd / costQuotaWindowSec)11.0.0 起只住这里:
901
+ // 旧位 `runtime` 域已retired(墓碑见 {@link RETIRED_DOMAINS};存量由一次性迁移 SS-M1 搬进本域,本域已有该键则旧值丢弃 —— 那是当年消费方
902
+ // 「新位赢」的生效读法)。
812
903
  //
813
904
  // 判据分工(**宽进严出**,与既有域一致):本域只判**结构**——类型、有限数、数组/记录的元素类型。
814
905
  // 业务域值(下限、`0` 关断哨兵、跨键不变量如「窗长必 > 0」「runStaleSec 必严格大于心跳」)的判据属主
@@ -828,7 +919,7 @@ export const InfraCostRates = z
828
919
  })
829
920
  .passthrough();
830
921
  export const LimitsConfig = z.object({
831
- // `.optional()` 无默认,与 RuntimeConfig 同纪律:ABSENT = 「center 不管这一键」⇒ 消费方回自己的 env 底;
922
+ // `.optional()` 无默认:ABSENT = 「web-admin 不管这一键」⇒ 消费方回自己的 env 底;
832
923
  // 显式值(**包括 0**)= 发布了一个真值(0 多半是「关断哨兵」,语义归消费方)。给默认值 = 静默覆盖部署。
833
924
  /** 每 principal 每分钟请求上限。 */
834
925
  rateLimitPerMin: z.number().finite().optional(),
@@ -871,7 +962,7 @@ export const LimitsConfig = z.object({
871
962
  // · 默认的 strip 会把这个窗口变成**键被静默剥掉 ⇒ 那条天花板悄悄回落到部署 env 底** —— 正是本域
872
963
  // 要根治的那种病换了个位置复发。已知键照旧严判结构(坏值 ⇒ 本域 throw 档 ⇒ 消费方 fail-static
873
964
  // 在上一份好配置);未知键原样承运,由消费方(键表在它手里)决定认或不认。
874
- // · 附带收益:老编辑器(TOC)写回新 center 的 limits 文档时不会把自己不认识的键写没了(strip 语义下
965
+ // · 附带收益:老编辑器(TOC)写回新 web-admin 的 limits 文档时不会把自己不认识的键写没了(strip 语义下
875
966
  // writeDomainFile 会真的丢键)。
876
967
  // · **承运 ≠ 沉默**(对抗复审 复审 round2 F2):未知成员会被 `"unknown-keys-carried"` 警告逐个点名
877
968
  // (config-fns 的已知键表由本 schema 的 shape 单源派生)——拼错的键名于是有人说,而值仍然到得了
@@ -882,13 +973,13 @@ export const LimitsConfig = z.object({
882
973
  // ── Domain: governance (治理三件 — split out of runtime, EXPERT-REDESIGN §5) ──────
883
974
  // The governance trio an operator tunes via publish: autonomy ladder + command guardrails + approval-required
884
975
  // tools. NOT下沉 — gets its own admin tab (E5 form; E1 ships a transitional generic editor). Same `.optional()`
885
- // no-default discipline as the six gates: ABSENT = center 不管这项(keep env / 消费者默认),present 才生效。
976
+ // no-default discipline as the six gates: ABSENT = web-admin 不管这项(keep env / 消费者默认),present 才生效。
886
977
  export const GovernanceConfig = z.object({
887
978
  // autonomy:一个跨 knob 的安全总开关 / 放权梯度(普通用户敢用 + 专业用户放权)。
888
979
  // read-only(只读规划,不落改)| ask(每个放权动作问)| plan(plan-review 门)| auto(放开)。
889
980
  // ⚠️ 消费侧(service)把它**展开**到 core 已有三原语(handsReadOnly / shellGate+onAsk / plan_review),
890
981
  // 经 core 的收紧-only `tightenTaskSpec` 合并。core 裁决:不在 core 加并行 autonomy 枚举(双实现陷阱),
891
- // 不静默覆盖调用者已设安全字段(auto 模式尤其不能静默清门控)。详见 sema-registry docs/CONFIG-RESEARCH §10。
982
+ // 不静默覆盖调用者已设安全字段(auto 模式尤其不能静默清门控)。详见 web-admin docs/CONFIG-RESEARCH §10。
892
983
  autonomy: z.enum(["read-only", "ask", "plan", "auto"]).optional(),
893
984
  // commandPolicy:命令级 allow/ask/deny 护栏(remoteExec 管"在哪跑",这管"能跑什么命令")。每条 = 一个
894
985
  // **精确 argv[0] 命令名** → 决策;**DENY-WINS**(冲突 deny 赢,与 core `combinePolicies` 一致,NOT last-match-wins)。
@@ -899,8 +990,7 @@ export const GovernanceConfig = z.object({
899
990
  // ⚠️ COARSE / 非沙箱:只匹配 leading simple-command 名(经 core `parseLeadingCommandName` fail-closed 解析器);
900
991
  // `$(...)` / `sh -c` / `env` / `xargs` / `find -exec` / `python -c` / 子命令都绕得过——是 egress/irreversibility
901
992
  // 安全轴之下的**纵深防御,绝非唯一隔离**(真隔离靠 executionEnv sandbox)。消费侧(service)必须经
902
- // `combinePolicies(部署默认, 本策略)` 编译进 `TaskSpec.toolPolicy`,绝不裸覆盖清掉部署级护栏;`shellGate:
903
- // "classify"` 下普通 ask 会被 irreversibility 提升为不可预算的 irreversible_ask。core 建
993
+ // `combinePolicies(部署默认, 本策略)` 编译进 `TaskSpec.toolPolicy`,绝不裸覆盖清掉部署级护栏。core 建
904
994
  // `createCoarseCommandNamePolicy` 消费它(config-research §10)。
905
995
  commandPolicy: z
906
996
  .array(z.object({
@@ -915,13 +1005,9 @@ export const GovernanceConfig = z.object({
915
1005
  decision: z.enum(["allow", "ask", "deny"]),
916
1006
  }))
917
1007
  .optional(),
918
- /** Tool names needing operator approval (F4). Moved verbatim from runtime (same ABSENT/explicit-[] semantics). */
1008
+ /** Tool names needing operator approval (F4). ABSENT = web-admin 不管;显式 `[]` = 一个都不需要审批。 */
919
1009
  approvalRequire: z.array(z.string()).optional(),
920
1010
  });
921
- /** The governance trio keys. 0.7.1:写出方向的 runtime 双写镜像已撤(service 1.150.0 已切新位,
922
- * 回执确认)——此常量今日唯一职责=READ 方向的 legacy lift(governance 域从未设时从旧 runtime 文档抬升,
923
- * config-fns legacyGovernanceFromRuntime),单一键源防逐键漂移。 */
924
- export const GOVERNANCE_MIRROR_KEYS = ["autonomy", "commandPolicy", "approvalRequire"];
925
1011
  // ── integrations domain: DELETED in 0.6.0 (EXPERT-REDESIGN §9 — 零消费者实锤:service 明确排除,OA/Gitea
926
1012
  // 实走 worker.env)。“按 scope connect GitHub” 是 backlog 的全新设计,不复用此形状。
927
1013
  // ── access domain: DELETED in 0.6.0 (EXPERT-REDESIGN §9 — 彻底删除不留兼容;唯一消费者 agent-client portal
@@ -933,25 +1019,25 @@ export const GOVERNANCE_MIRROR_KEYS = ["autonomy", "commandPolicy", "approvalReq
933
1019
  // every principal's tier/budget to a worker), and `/effective?principal=` resolves the per-principal view
934
1020
  // (effective = worker-base ∩ tier⊕roles ∩ time-bounded overrides; intersection = either side's "deny" wins).
935
1021
  // Minimal records — never raw usage numbers (those live in the data plane / telemetry).
936
- /** Token budget — LiteLLM-interop vocab + the center-specific DEGRADE mode + a per-RUN admission gate. */
1022
+ /** Token budget — LiteLLM-interop vocab + web-admin-specific DEGRADE mode + a per-RUN admission gate. */
937
1023
  export const EntitlementBudget = z.object({
938
- /** soft = alert only · hard = pre-call block · degrade = fall back to a cheaper model (center-specific). */
1024
+ /** soft = alert only · hard = pre-call block · degrade = fall back to a cheaper model (web-admin-specific). */
939
1025
  mode: z.enum(["soft", "hard", "degrade"]).optional(),
940
1026
  // .finite() on every $/USD float: z.number().nonnegative() accepts Infinity, which JSON.stringify's to null on
941
1027
  // persist → a later strict reparse throws (fail-open on write, fail-hard on read). Same discipline as ModelCost.
942
1028
  maxBudgetUsd: z.number().finite().nonnegative().optional(),
943
1029
  budgetDuration: z.string().optional(), // e.g. "30d"
944
- tpmLimit: z.number().int().nonnegative().optional(),
945
- rpmLimit: z.number().int().nonnegative().optional(),
946
- maxParallelRequests: z.number().int().nonnegative().optional(),
947
- perModel: z.record(z.string(), z.object({ maxBudgetUsd: z.number().finite().nonnegative().optional(), tpmLimit: z.number().int().nonnegative().optional(), rpmLimit: z.number().int().nonnegative().optional() })).optional(),
1030
+ tpmLimit: INTEGER.nonnegative().optional(),
1031
+ rpmLimit: INTEGER.nonnegative().optional(),
1032
+ maxParallelRequests: INTEGER.nonnegative().optional(),
1033
+ perModel: z.record(z.string(), z.object({ maxBudgetUsd: z.number().finite().nonnegative().optional(), tpmLimit: INTEGER.nonnegative().optional(), rpmLimit: INTEGER.nonnegative().optional() })).optional(),
948
1034
  /** Per-RUN admission gate (agentic-runaway guard, §3.3 🔴) — period budgets can't stop ONE runaway run. */
949
1035
  maxRunCostUsd: z.number().finite().nonnegative().optional(),
950
- maxIterations: z.number().int().positive().optional(),
1036
+ maxIterations: INTEGER.positive().optional(),
951
1037
  /** WHERE this budget is enforced (scheduling framework D4, 产品拍板 2026-07-04): `local` = worker-only
952
- * (per-instance or shared-DB — today's posture) · `lease` = AP pre-authorized QuotaLease from center +
1038
+ * (per-instance or shared-DB — today's posture) · `lease` = AP pre-authorized QuotaLease from web-admin +
953
1039
  * after-the-fact reconciliation (default for sema-managed fleets) · `central` = CP pre-call check against
954
- * center (strict tenants; slow but exact). ABSENT = local. Orthogonal to `mode` (what happens on breach). */
1040
+ * web-admin (strict tenants; slow but exact). ABSENT = local. Orthogonal to `mode` (what happens on breach). */
955
1041
  enforcement: z.enum(["local", "lease", "central"]).optional(),
956
1042
  });
957
1043
  /** Token 双窗额度(配额轴, EXPERT-REDESIGN §4 — Claude 式 5h 滚动窗 + 自然周窗),单位=加权 tokens
@@ -961,9 +1047,9 @@ export const EntitlementBudget = z.object({
961
1047
  * 更严;tokenQuota 不参与 budget 的 min-of-budgets 归并。任一窗耗尽即拒(CC 式拒绝+恢复时间,E4)。 */
962
1048
  export const TokenQuota = z.object({
963
1049
  /** 5h 滚动窗额度(加权 tokens)。ABSENT = 此窗不设限。 */
964
- fiveHourTokens: z.number().int().nonnegative().optional(),
1050
+ fiveHourTokens: INTEGER.nonnegative().optional(),
965
1051
  /** 自然周窗额度(加权 tokens)。ABSENT = 此窗不设限。 */
966
- weeklyTokens: z.number().int().nonnegative().optional(),
1052
+ weeklyTokens: INTEGER.nonnegative().optional(),
967
1053
  });
968
1054
  /** 预算组(共享钱池,§4-1)——与个人 budget 双轨并行:一笔花费计入个人轨 + 所有所属池;多池并存取最先
969
1055
  * 耗尽;tokens 配额轴(TokenQuota)与它是两轨,先到顶者生效。成员条目带 `memberSince`:池只聚合入组时刻
@@ -982,60 +1068,60 @@ export const BudgetGroup = z.object({
982
1068
  .object({
983
1069
  /** instance-level group ids(registry `groups` 表——store 对象,zod 看不见,存在性同 groupBindings
984
1070
  * 先例不做 schema 校验,dangling 由 UI 标红)。 */
985
- groups: z.array(BudgetGroupMember.extend({ group: z.string().min(1) })).default([]),
986
- principals: z.array(BudgetGroupMember.extend({ principal: z.string().min(1) })).default([]),
1071
+ groups: z.array(BudgetGroupMember.extend({ group: z.string().min(1) })).prefault([]),
1072
+ principals: z.array(BudgetGroupMember.extend({ principal: z.string().min(1) })).prefault([]),
987
1073
  })
988
- .default({ groups: [], principals: [] }),
1074
+ .prefault({ groups: [], principals: [] }),
989
1075
  });
990
1076
  /** 生效场景分配(§7,与配额同一挂载心智):全局默认 + 按组 + 按用户三层;解析优先级=用户 > 组 > 全局默认
991
- * > worker 级 runtime.scenario 兜底(引擎兜底行为待 ask② 确认)。`scenario` 是场景 NAME——可指 center
1077
+ * > worker 级 runtime.scenario 兜底(引擎兜底行为待 ask② 确认)。`scenario` 是场景 NAME——可指 web-admin
992
1078
  * scenarios 域条目,也可指 service 内建场景名(内建不在域里,故**不做** cross-domain 存在性硬校验,
993
1079
  * dangling 由 UI 标注)。 */
994
1080
  export const ScenarioAssignments = z.object({
995
1081
  /** 全局默认场景名。ABSENT = 不设全局默认(落到 worker 级 runtime.scenario / 引擎内建)。 */
996
1082
  default: z.string().min(1).optional(),
997
- groups: z.array(z.object({ group: z.string().min(1), scenario: z.string().min(1) })).default([]),
998
- principals: z.array(z.object({ principal: z.string().min(1), scenario: z.string().min(1) })).default([]),
1083
+ groups: z.array(z.object({ group: z.string().min(1), scenario: z.string().min(1) })).prefault([]),
1084
+ principals: z.array(z.object({ principal: z.string().min(1), scenario: z.string().min(1) })).prefault([]),
999
1085
  });
1000
1086
  /** Org-forced-off runtime ceilings (gate②). ABSENT = no ceiling (local controls); `false` = org-disabled
1001
1087
  * fleet-wide. Only ever pushes the "off" ceiling — never force-enables (monotone tighten-only). */
1002
1088
  export const EntitlementRuntimeCaps = z.object({
1003
1089
  allowBypassPermissions: z.boolean().optional(),
1004
1090
  allowAutoMode: z.boolean().optional(),
1005
- /** Per-principal governance of the workflow/ultracode capability (K-2, 产品拍板 2026-06-28). center treats this as
1091
+ /** Per-principal governance of the workflow/ultracode capability (K-2, 产品拍板 2026-06-28). web-admin treats this as
1006
1092
  * the "准不准" axis; the engine separately advertises "能不能" (Capabilities.workflows). The shell enables
1007
1093
  * ultracode/workflows iff engine-capable ∧ principal-entitled. Like the others: ABSENT = no ceiling,
1008
1094
  * `false` = org-disabled fleet-wide (tighten-only false-wins; never force-enables). Full server-side
1009
1095
  * enforcement awaits core adding a per-principal authz axis that consumes this; until then it gates the shell UI. */
1010
1096
  allowWorkflows: z.boolean().optional(),
1011
- /** Per-principal governance of the Fork (fork-session) tool — center's "准不准" axis, mirroring allowWorkflows.
1097
+ /** Per-principal governance of the Fork (fork-session) tool — web-admin's "准不准" axis, mirroring allowWorkflows.
1012
1098
  * The engine advertises "能不能" separately; a Fork is admitted iff engine-capable ∧ principal-entitled. Like the
1013
1099
  * others: ABSENT = no ceiling (Fork allowed), `false` = org-disabled fleet-wide (tighten-only false-wins; never
1014
1100
  * force-enables). core (RuntimeCaps.allowFork, prepare-task) consumes this per-principal: `allowFork === false`
1015
1101
  * → Fork DENIED, else allowed. */
1016
1102
  allowFork: z.boolean().optional(),
1017
1103
  /** Per-principal governance of observer agents (core 1.270.0/1.273.0 OBS-2) —
1018
- * center's "准不准" axis for the AgentDefinition.observer pairing surface. ⚠️ core's consumption polarity is
1104
+ * web-admin's "准不准" axis for the AgentDefinition.observer pairing surface. ⚠️ core's consumption polarity is
1019
1105
  * INVERTED vs the other allow* caps: the engine is `=== true` explicit opt-in (default OFF — an undefined cap
1020
1106
  * ships dark), so this key is the ENABLE line, not just a ceiling. Three-state semantics (ABSENT ≠ false):
1021
- * ABSENT = center says nothing → key NOT emitted on the wire (core stays the SOLE default source; service's
1107
+ * ABSENT = web-admin says nothing → key NOT emitted on the wire (core stays the SOLE default source; service's
1022
1108
  * toCoreRuntimeCaps must not backfill a missing key into an explicit verdict — that would poison the
1023
1109
  * env-baseline compose order); `true` = explicitly enable observer agents for this principal; `false` =
1024
- * explicit org deny — overrides the node-level env baseline EXPERIMENTAL_OBSERVER_AGENTS (center speaks →
1025
- * center wins). On compose across bundles, `false` (deny) still wins — see RUNTIME_CAP_STRICT; core's
1110
+ * explicit org deny — overrides the node-level env baseline EXPERIMENTAL_OBSERVER_AGENTS (web-admin speaks →
1111
+ * web-admin wins). On compose across bundles, `false` (deny) still wins — see RUNTIME_CAP_STRICT; core's
1026
1112
  * `=== true` opt-in check is orthogonal to that merge polarity. */
1027
1113
  allowObservers: z.boolean().optional(),
1028
1114
  /** Per-principal ARM switch for the auto-mode CLASSIFIER (core 1.276 批2 seam `RuntimeCaps.autoMode`, 板
1029
1115
  * A surviving permission `ask` is routed to the small-model security classifier before any
1030
1116
  * human/durable resolution). ⚠️ Same INVERTED consumption polarity as `allowObservers`: core is `=== true`
1031
1117
  * explicit opt-in, default OFF — this key is the ENABLE line, not just a ceiling. Three-state semantics
1032
- * (ABSENT ≠ false): ABSENT = center says nothing → key NOT emitted on the wire (core stays the SOLE default
1118
+ * (ABSENT ≠ false): ABSENT = web-admin says nothing → key NOT emitted on the wire (core stays the SOLE default
1033
1119
  * source — never backfill absence into an explicit verdict); `true` = explicitly arm the classifier for this
1034
1120
  * principal (a no-op on a deployment without a wired classify leg — the capability gate lives in core);
1035
1121
  * `false` = explicit org deny. On compose across bundles `false` (deny) wins — see RUNTIME_CAP_STRICT;
1036
1122
  * core's `=== true` opt-in check is orthogonal to that merge polarity. DIVISION OF LABOR vs
1037
1123
  * {@link allowAutoMode}: `allowAutoMode` is the 准入 may-I ceiling on the USER-facing auto permission mode
1038
- * (shell/settings face, tighten-only); `autoMode` is center's 部署 arm decision for the server-side
1124
+ * (shell/settings face, tighten-only); `autoMode` is web-admin's 部署 arm decision for the server-side
1039
1125
  * classifier leg itself. */
1040
1126
  autoMode: z.boolean().optional(),
1041
1127
  allowUltracode: z.boolean().optional(),
@@ -1043,18 +1129,18 @@ export const EntitlementRuntimeCaps = z.object({
1043
1129
  * (design/383 §3.1): may this principal declare `memory.capture: "off"` for a run? `false` = the deployment
1044
1130
  * REQUIRES capture for this principal (compliance / knowledge retention) — the declaration is refused, terminal
1045
1131
  * (`memory.capture_optout_denied`). `true` = explicitly no per-principal restriction. Three-state, ABSENT ≠ false:
1046
- * ABSENT = center says nothing → key NOT emitted on the wire (never coined into a verdict). The consumer's
1132
+ * ABSENT = web-admin says nothing → key NOT emitted on the wire (never coined into a verdict). The consumer's
1047
1133
  * fault-transit contract for THIS member runs opposite to the `{allowWorkflows:false}` degrade habit: a resolver
1048
1134
  * FAULT must surface as ABSENT (or a throw), never as a stand-in `false` — on the privacy axis "deny" is the
1049
1135
  * IRREVERSIBLE side (capturing a session the user said not to capture), so the default/`open` posture reads a
1050
1136
  * fault as "opt-out honoured + named disclosure", and a `governed` posture reads it as "verdict missing ⇒
1051
- * refuse the run". The consumer composes this key from its own local grant store FIRST and the center value
1137
+ * refuse the run". The consumer composes this key from its own local grant store FIRST and web-admin value
1052
1138
  * second. Compose polarity across bundles is the allow* family's: `false` (capture required) wins — see
1053
- * RUNTIME_CAP_STRICT; the consumer's posture arms are orthogonal to that merge. Consumer-side: the center value
1139
+ * RUNTIME_CAP_STRICT; the consumer's posture arms are orthogonal to that merge. Consumer-side: web-admin value
1054
1140
  * reaches the engine caps only once the consumer's cap mapping picks this member up; until then its local grant
1055
1141
  * store is the sole verdict source (the key parses and is carried on the wire from this version on). */
1056
1142
  allowMemoryOptOut: z.boolean().optional(),
1057
- /** Force this principal's run onto durable-approval (per-tool gate) — center's fleet-wide handle for CC-faithful
1143
+ /** Force this principal's run onto durable-approval (per-tool gate) — web-admin's fleet-wide handle for CC-faithful
1058
1144
  * per-tool interception (K-3, core confirmed it consumes this). Polarity is INVERTED vs the allow* caps:
1059
1145
  * here `true` is the STRICT/tightening value (forcing the gate ON), so it wins on compose — see RUNTIME_CAP_STRICT.
1060
1146
  * ABSENT = no fleet override (local/default resolution decides). Server-side enforcement = core's per-principal axis. */
@@ -1106,7 +1192,7 @@ export const EntitlementOverride = z
1106
1192
  key: z.string().min(1),
1107
1193
  /** The override's value — its SHAPE follows its `key`, so it is parse-transparent here and validated by
1108
1194
  * whoever enforces that key: {@link PARSE_TRANSPARENT_KEYS} (`entitlement.subjects[].overrides[].value`). */
1109
- value: z.unknown(),
1195
+ value: z.unknown().optional(),
1110
1196
  effectiveFrom: z.string().optional(),
1111
1197
  effectiveUntil: z.string().optional(),
1112
1198
  approvedBy: z.string().min(1),
@@ -1118,15 +1204,15 @@ export const EntitlementOverride = z
1118
1204
  // by env-NAME, never inline. (Budget overrides are numbers/enums, so this never fires on the v1 use.)
1119
1205
  const s = typeof o.value === "string" ? o.value : o.value == null ? "" : JSON.stringify(o.value);
1120
1206
  if (s && containsSecretToken(s))
1121
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: "override.value appears to contain a secret token — overrides are persisted + audited; reference secrets by env-NAME, never inline a value", path: ["value"] });
1207
+ ctx.addIssue({ code: "custom", message: "override.value appears to contain a secret token — overrides are persisted + audited; reference secrets by env-NAME, never inline a value", path: ["value"] });
1122
1208
  });
1123
1209
  export const EntitlementSubject = z.object({
1124
1210
  principal: z.string().min(1),
1125
1211
  displayName: z.string().optional(),
1126
1212
  tierId: z.string().optional(),
1127
1213
  /** Additional tier ids composed on top of `tierId` (union of allows, min of budgets) — the "role combo". */
1128
- roles: z.array(z.string()).default([]),
1129
- overrides: z.array(EntitlementOverride).default([]),
1214
+ roles: z.array(z.string()).prefault([]),
1215
+ overrides: z.array(EntitlementOverride).prefault([]),
1130
1216
  /** 用户层 token 双窗额度(最具体层,压过组与全局默认——极性见 {@link TokenQuota})。 */
1131
1217
  tokenQuota: TokenQuota.optional(),
1132
1218
  });
@@ -1141,7 +1227,7 @@ export const EntitlementGroupBinding = z.object({
1141
1227
  group: z.string().min(1),
1142
1228
  tierId: z.string().optional(),
1143
1229
  /** Additional tier ids composed on top of `tierId` — same "role combo" shape as EntitlementSubject. */
1144
- roles: z.array(z.string()).default([]),
1230
+ roles: z.array(z.string()).prefault([]),
1145
1231
  /** 组层 token 双窗额度(同层多组命中取更严;被用户层压过——极性见 {@link TokenQuota})。 */
1146
1232
  tokenQuota: TokenQuota.optional(),
1147
1233
  });
@@ -1158,12 +1244,12 @@ export const EntitlementConfig = z
1158
1244
  tokenQuota: TokenQuota.optional(),
1159
1245
  })
1160
1246
  .optional(),
1161
- tiers: z.array(EntitlementTier).default([]),
1162
- subjects: z.array(EntitlementSubject).default([]),
1247
+ tiers: z.array(EntitlementTier).prefault([]),
1248
+ subjects: z.array(EntitlementSubject).prefault([]),
1163
1249
  /** Group bindings (W4, additive 0.4.0) — bind an instance-level GROUP to a tier ⊕ roles combo. */
1164
- groupBindings: z.array(EntitlementGroupBinding).default([]),
1250
+ groupBindings: z.array(EntitlementGroupBinding).prefault([]),
1165
1251
  /** 预算组(共享钱池,0.6.0 §4)——见 {@link BudgetGroup}。 */
1166
- budgetGroups: z.array(BudgetGroup).default([]),
1252
+ budgetGroups: z.array(BudgetGroup).prefault([]),
1167
1253
  /** 生效场景分配(0.6.0 §7)——见 {@link ScenarioAssignments}。 */
1168
1254
  scenarioAssignments: ScenarioAssignments.optional(),
1169
1255
  })
@@ -1171,53 +1257,53 @@ export const EntitlementConfig = z
1171
1257
  const tierIds = new Set();
1172
1258
  cfg.tiers.forEach((t, i) => {
1173
1259
  if (tierIds.has(t.id))
1174
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate tier id "${t.id}"`, path: ["tiers", i, "id"] });
1260
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate tier id "${t.id}"`, path: ["tiers", i, "id"] });
1175
1261
  tierIds.add(t.id);
1176
1262
  });
1177
1263
  const seenPools = new Set();
1178
1264
  cfg.budgetGroups.forEach((b, i) => {
1179
1265
  if (seenPools.has(b.id))
1180
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate budget group id "${b.id}"`, path: ["budgetGroups", i, "id"] });
1266
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate budget group id "${b.id}"`, path: ["budgetGroups", i, "id"] });
1181
1267
  seenPools.add(b.id);
1182
1268
  });
1183
1269
  const seenGroups = new Set();
1184
1270
  cfg.groupBindings.forEach((g, i) => {
1185
1271
  if (seenGroups.has(g.group))
1186
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate group binding "${g.group}"`, path: ["groupBindings", i, "group"] });
1272
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate group binding "${g.group}"`, path: ["groupBindings", i, "group"] });
1187
1273
  seenGroups.add(g.group);
1188
1274
  if (g.tierId && !tierIds.has(g.tierId))
1189
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `group binding "${g.group}" → unknown tier "${g.tierId}"`, path: ["groupBindings", i, "tierId"] });
1275
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `group binding "${g.group}" → unknown tier "${g.tierId}"`, path: ["groupBindings", i, "tierId"] });
1190
1276
  g.roles.forEach((r, j) => { if (!tierIds.has(r))
1191
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `group binding "${g.group}" → unknown role/tier "${r}"`, path: ["groupBindings", i, "roles", j] }); });
1277
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `group binding "${g.group}" → unknown role/tier "${r}"`, path: ["groupBindings", i, "roles", j] }); });
1192
1278
  });
1193
1279
  const seen = new Set();
1194
1280
  cfg.subjects.forEach((s, i) => {
1195
1281
  if (seen.has(s.principal))
1196
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate subject principal "${s.principal}"`, path: ["subjects", i, "principal"] });
1282
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate subject principal "${s.principal}"`, path: ["subjects", i, "principal"] });
1197
1283
  seen.add(s.principal);
1198
1284
  if (s.tierId && !tierIds.has(s.tierId))
1199
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `subject "${s.principal}" → unknown tier "${s.tierId}"`, path: ["subjects", i, "tierId"] });
1285
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `subject "${s.principal}" → unknown tier "${s.tierId}"`, path: ["subjects", i, "tierId"] });
1200
1286
  s.roles.forEach((r, j) => { if (!tierIds.has(r))
1201
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `subject "${s.principal}" → unknown role/tier "${r}"`, path: ["subjects", i, "roles", j] }); });
1287
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `subject "${s.principal}" → unknown role/tier "${r}"`, path: ["subjects", i, "roles", j] }); });
1202
1288
  s.overrides.forEach((o, j) => { if (o.principal !== s.principal)
1203
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `override.principal "${o.principal}" must equal subject "${s.principal}"`, path: ["subjects", i, "overrides", j, "principal"] }); });
1289
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `override.principal "${o.principal}" must equal subject "${s.principal}"`, path: ["subjects", i, "overrides", j, "principal"] }); });
1204
1290
  });
1205
1291
  });
1206
- /** design/170 件A:`?principal=` caps wire 上的 org 记忆解析段(center 铸、server 消费,两侧共此同源锚)。
1207
- * 段级三值语义:**键整体缺席**=center 不支持该段(能力握手,消费方按「取不到」瞬时处理,不判终局);
1292
+ /** design/170 件A:`?principal=` caps wire 上的 org 记忆解析段(web-admin 铸、server 消费,两侧共此同源锚)。
1293
+ * 段级三值语义:**键整体缺席**=web-admin 不支持该段(能力握手,消费方按「取不到」瞬时处理,不判终局);
1208
1294
  * **`scopes: {}` 空表**=取数成功的负结果(该 principal 零 org 授权,终局);**有表**=逐条目判。
1209
1295
  * `gen`=单调世代号(publish 模式=globalRevision,live 模式=store version;消费方拒退挂此,防线为
1210
- * 进程生命周期内的 LB 陈旧副本——重启后无高水位=接受 center 当刻值,center 是真源)。 */
1296
+ * 进程生命周期内的 LB 陈旧副本——重启后无高水位=接受 web-admin 当刻值,web-admin 是真源)。 */
1211
1297
  export const PrincipalOrgMemoryWire = z
1212
1298
  .object({
1213
- gen: z.number().int().nonnegative(),
1299
+ gen: INTEGER.nonnegative(),
1214
1300
  scopes: z.record(z.string().regex(/^org:\S+$/, "orgMemory scope must be an `org:` scope string"), z.object({ write: z.boolean().optional() }).strict()),
1215
1301
  })
1216
1302
  .strict();
1217
- /** `?principal=` caps wire **整体**的读边界 schema(center 铸、server 消费同源锚;产品令 2026-08-05:
1303
+ /** `?principal=` caps wire **整体**的读边界 schema(web-admin 铸、server 消费同源锚;产品令 2026-08-05:
1218
1304
  * 网络边界读入必 schema,禁裸 as-cast)。**逐面容错极性是裁定过的语义**,严格化会翻转极性的面在此
1219
1305
  * 显式保持宽读(每面注明所依裁定),不许硬转严:
1220
- * - `runtimeCaps`/`budget`(治理面,fail-closed):严格校验已知键 + **passthrough 未知键**——新 center
1306
+ * - `runtimeCaps`/`budget`(治理面,fail-closed):严格校验已知键 + **passthrough 未知键**——新 web-admin
1221
1307
  * 的新 cap 键必须原样透传给 core(老 server 剥键=静默放松治理);值漂移 ⇒ 整体 parse 失败 ⇒ 消费端
1222
1308
  * fail-close(「outage 绝不静默 GRANT」的既裁方向)。
1223
1309
  * - `scenario`/`allowlist`/`execution`(能力面,fail-open,[565]/[580] 裁定):保持 `unknown` 宽读,
@@ -1268,18 +1354,15 @@ export const ActorAssertionWire = z
1268
1354
  /** sessionMirror TARGET url 的底线传输校验(0.10.1;0.10.2 加 trim 前置):**https,或 loopback http**
1269
1355
  * (本机第二引擎/dev)。与壳 `evaluateEngineRouting`(shell-host engineTarget.ts,S1 路由闸)语义对齐:
1270
1356
  * 镜像上行带会话内容+Bearer 凭证,非 loopback 明文 http = 凭证与会话数据裸奔——**写侧 schema 层就拒**
1271
- * (fail-loud 比读侧执法更早;center 编辑面存不进去,壳读侧闸只兜手改存储的漏网)。基线复用
1272
- * {@link NON_SECRET_URL}(禁 userinfo 凭证/禁 query/fragment 夹带 secret-shaped 值)。loopback 判定与壳
1273
- * isLoopbackEngineUrl 逐字同表:127.0.0.1 / localhost / ::1 / [::1](URL.hostname 对 IPv6 带方括号,
1274
- * 两写法都收)。**trim 前置(0.10.2,附B 三单合评审 M-1)**:前导/尾随空白先归一再验且存储值即 trim
1357
+ * (fail-loud 比读侧执法更早;web-admin 编辑面存不进去,壳读侧闸只兜手改存储的漏网)。基线复用
1358
+ * {@link NON_SECRET_URL}(禁 userinfo 凭证/禁 query/fragment 夹带 secret-shaped 值)。loopback 判定 = 鉴权放宽那一只
1359
+ * {@link isLoopbackAuthHost}(11.0.0 起;明文 http 带凭据是放宽,窄是安全方向 —— 壳的同一判定改读同一只)。
1360
+ * **trim 前置(0.10.2,附B 三单合评审 M-1)**:前导/尾随空白先归一再验且存储值即 trim
1275
1361
  * 后值——否则 " https://…" 写侧收(new URL 容忍前导空白)而壳读侧逐字符比对拒 = 全员镜像静默 degraded。 */
1276
1362
  export const SESSION_MIRROR_ENGINE_URL = z.string().trim().pipe(NON_SECRET_URL).refine((s) => {
1277
1363
  try {
1278
1364
  const u = new URL(s);
1279
- if (u.protocol === "https:")
1280
- return true;
1281
- const h = u.hostname;
1282
- return h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "[::1]";
1365
+ return u.protocol === "https:" || isLoopbackAuthHost(u.hostname);
1283
1366
  }
1284
1367
  catch {
1285
1368
  return true; // already passed NON_SECRET_URL; unreachable in practice
@@ -1298,7 +1381,7 @@ export const SESSION_MIRROR_ENGINE_URL = z.string().trim().pipe(NON_SECRET_URL).
1298
1381
  * - `required:true` = **org 治理强制**:会话镜像默认开且不可关,且**本地覆盖不生效——既不能改目标
1299
1382
  * 也不能关闭(org 压一切;附B 三单合评审 MAJOR-1 勘误:治理态下本地 --target 完全被忽略)**
1300
1383
  * (审计场景;壳复用 CC managed-settings 锁定报错串——S3 execution 沙箱锁完全同构先例)。
1301
- * 整个 sessionMirror **optional,缺省 INERT**(无下发=老 center/老壳/0.8-0.10 存量 execution 文档
1384
+ * 整个 sessionMirror **optional,缺省 INERT**(无下发=老 web-admin/老壳/0.8-0.10 存量 execution 文档
1302
1385
  * 零行为差、零迁移)。⚠️ ENFORCEMENT HONESTY 同域头注:required 的锁今天也是 CLIENT-SIDE 执法。
1303
1386
  * 开放演化:v2 可能加 audit 字段类(保留窗/脱敏面等)——本对象刻意**不 .strict()**(zod 默认 strip
1304
1387
  * 未知键:v2 文档被 v1 消费者读到=未知键剥除,forward-compatible;加字段恒 additive patch)。 */
@@ -1309,7 +1392,7 @@ export const SessionMirrorPolicy = z.object({
1309
1392
  required: z.boolean().default(false),
1310
1393
  });
1311
1394
  /** 执行域 sealed-box 加密公钥登记(0.10.5,密钥托管线;挂 execution 域=「执行环境的属性」循
1312
- * sessionMirror 收编先例,零新域)。center **只登记公钥**——私钥由执行侧(service worker/引擎)
1395
+ * sessionMirror 收编先例,零新域)。web-admin **只登记公钥**——私钥由执行侧(service worker/引擎)
1313
1396
  * 生成并持有,永不上行;浏览器端用这里的公钥 crypto_box_seal 加密模型 key(见 {@link SealedApiKey})。
1314
1397
  * X25519 公钥恒 32 字节 → base64 恒 43 字符 + "=",regex 钉死(错长度=根本不是 X25519 公钥,写侧
1315
1398
  * fail-loud 比执行侧解密失败早得多)。 */
@@ -1318,11 +1401,11 @@ export const ExecutionPublicKey = z.object({
1318
1401
  publicKeyId: z.string().min(1).max(128),
1319
1402
  /** X25519 公钥,base64(32 字节 → 恒 44 字符含 padding)。 */
1320
1403
  publicKey: z.string().regex(/^[A-Za-z0-9+/]{43}=$/, "publicKey must be a base64 X25519 public key (32 bytes)"),
1321
- /** 登记时刻(ISO)。center 写面 server 端盖章(projects.registeredAt 同款纪律)。 */
1404
+ /** 登记时刻(ISO)。web-admin 写面 server 端盖章(projects.registeredAt 同款纪律)。 */
1322
1405
  createdAt: z.string().min(1),
1323
1406
  notes: z.string().optional(),
1324
1407
  /** 归属 worker 标识(0.10.9,service 合意)。**取值 = 上报方 worker 的
1325
- * `SEMA_REGISTRY_WORKER` env**(service 上报载荷带 `workerId`;该 env 未配 = 单 worker 部署,
1408
+ * `SEMA_WEB_ADMIN_WORKER` env**(service 上报载荷带 `workerId`;该 env 未配 = 单 worker 部署,
1326
1409
  * 上报省略本字段)。多 worker 部署下 seal 侧凭它按**目标 worker** 选钥(A worker 的密文只有
1327
1410
  * A 的私钥能解——选错钥 = 执行侧解密失败,晚且难查);ABSENT = 未绑定,seal 侧 fallback
1328
1411
  * newest(仅适合单 worker 部署)。optional additive:存量登记条目零迁移。 */
@@ -1336,8 +1419,8 @@ export const ExecutionPolicy = z.object({
1336
1419
  * must not be a BREAKING schema change (promised to the service). The closed set of lanes a worker can
1337
1420
  * declare is `EXECUTION_LANE_WORDS` (`execution-lanes.ts`, 7.0.0) — read it there, not from a copy in this
1338
1421
  * comment. Unknown strings are consumer-defined. */
1339
- allowedLanes: z.array(z.string().min(1)).default(["host"]),
1340
- /** Org 默认会话镜像目标(0.10.1,附B 两层)。ABSENT = INERT(center 未管镜像,壳解析序里本腿为空)。
1422
+ allowedLanes: z.array(z.string().min(1)).prefault(["host"]),
1423
+ /** Org 默认会话镜像目标(0.10.1,附B 两层)。ABSENT = INERT(web-admin 未管镜像,壳解析序里本腿为空)。
1341
1424
  * 读损档随域(DOMAIN_READ_FALLBACK.execution="throw" 已覆盖:损坏绝不静默退默认放权)。 */
1342
1425
  sessionMirror: SessionMirrorPolicy.optional(),
1343
1426
  /** 执行域 sealed-box 公钥登记表(0.10.5)。**optional 无 default,ABSENT = 未登记**(0.8–0.10 存量
@@ -1349,7 +1432,7 @@ export const ExecutionPolicy = z.object({
1349
1432
  const seen = new Set();
1350
1433
  (p.publicKeys ?? []).forEach((k, i) => {
1351
1434
  if (seen.has(k.publicKeyId))
1352
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate publicKeyId "${k.publicKeyId}"`, path: ["publicKeys", i, "publicKeyId"] });
1435
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate publicKeyId "${k.publicKeyId}"`, path: ["publicKeys", i, "publicKeyId"] });
1353
1436
  seen.add(k.publicKeyId);
1354
1437
  });
1355
1438
  });
@@ -1358,11 +1441,11 @@ export const ExecutionPolicy = z.object({
1358
1441
  // 登记簿(索引/展示/审计)+ 无仓 project 的 projectId 铸造权威。铸造离线自由、使用时注册执法
1359
1442
  // 佐证:执法位在 service worker(派生 `proj:*` 可读 scope 前查 `(tenant,projectId)`
1360
1443
  // 已注册),本域只是它查的那张表——登记簿**不读 marker 文件、不镜像 marker schema**(已确认)。
1361
- // 复合键 `(tenant, projectId)` 的 tenant 半边由 center 的 SCOPE 轴承载(api/scopes.ts:每 scope 一份
1444
+ // 复合键 `(tenant, projectId)` 的 tenant 半边由 web-admin 的 SCOPE 轴承载(api/scopes.ts:每 scope 一份
1362
1445
  // 独立域文档),域内 Record 键=projectId only;fork 后同一 projectId 合法存在于两个 scope,互不相见。
1363
1446
  /** projectId 形状:lowercase 规范 UUID。**宽读严写**(core 口径):读面/claim 收 generic
1364
1447
  * UUID(外部/历史铸造的 v4 也认——marker 是身份不是格式声明);铸造侧(core formatProjectMarker /
1365
- * center mint 端点)统一产 **uuidv7**。36 字符,天然满足 design/142 §1.2 段编码 64 上限。
1448
+ * web-admin mint 端点)统一产 **uuidv7**。36 字符,天然满足 design/142 §1.2 段编码 64 上限。
1366
1449
  * SINGLE-SOURCE 纪律:本常量是引擎那条正则的**自持镜像**(本仓不带引擎运行时依赖轴),对拍走 dev-only
1367
1450
  * conformance(never a runtime import,EffectiveKey/coreTypes 先例)。
1368
1451
  * 2.0.0:drift-lock 已建(`test/upstream-drift.test.ts` ③)—— pattern 必须与引擎逐字相同,唯一成文的差是
@@ -1382,23 +1465,23 @@ export const ProjectRegistration = z.object({
1382
1465
  .max(512)
1383
1466
  .refine((s) => !/\s/.test(s) && !s.includes("://"), "must be a NORMALIZED remote (host/path — no scheme, no whitespace)"))
1384
1467
  .max(16)
1385
- .default([]),
1468
+ .prefault([]),
1386
1469
  /** 该项目会话的默认记忆 scope 词表。OPEN value domain — z.string(),不 enum(纪律:加词
1387
1470
  * 不得 BREAKING)。方向(S4 细化):消费方=service memoryScope 派生(作 TaskSpec.
1388
1471
  * memory.scopes 种子),形态=v2 scope 键字符串(过 core parseScopeKey 校验;known 词根:`proj` /
1389
1472
  * `userproj`)。未知串=消费者自定义;空数组=不下发默认。本域只登记不解释,零耦合。 */
1390
- defaultScopes: z.array(z.string().min(1)).default([]),
1391
- /** 登记人 principal(审计锚;center 写面自动填 caller,不信客户端提交)。 */
1473
+ defaultScopes: z.array(z.string().min(1)).prefault([]),
1474
+ /** 登记人 principal(审计锚;web-admin 写面自动填 caller,不信客户端提交)。 */
1392
1475
  registeredBy: z.string().min(1),
1393
- /** 登记时刻(ISO)。center 写面 server 端盖章。 */
1476
+ /** 登记时刻(ISO)。web-admin 写面 server 端盖章。 */
1394
1477
  registeredAt: z.string().min(1),
1395
1478
  notes: z.string().optional(),
1396
1479
  });
1397
1480
  export const ProjectsConfig = z
1398
1481
  .object({
1399
- /** 键=projectId({@link PROJECT_ID_REGEX},generic lowercase UUID——宽读);tenant 半边=center
1482
+ /** 键=projectId({@link PROJECT_ID_REGEX},generic lowercase UUID——宽读);tenant 半边=web-admin
1400
1483
  * scope 轴(不进域文档,见域头注)。 */
1401
- projects: z.record(z.string().regex(PROJECT_ID_REGEX, "projectId must be a lowercase canonical UUID"), ProjectRegistration).default({}),
1484
+ projects: z.record(z.string().regex(PROJECT_ID_REGEX, "projectId must be a lowercase canonical UUID"), ProjectRegistration).prefault({}),
1402
1485
  })
1403
1486
  .superRefine((cfg, ctx) => {
1404
1487
  // 同 scope 内一条 remote 只许属于一个 project——「remote → projectId」找回阶梯(design/142
@@ -1409,7 +1492,7 @@ export const ProjectsConfig = z
1409
1492
  for (const [i, r] of reg.gitRemotes.entries()) {
1410
1493
  const prev = seen.get(r);
1411
1494
  if (prev !== undefined && prev !== pid) {
1412
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `remote "${r}" already registered to project "${prev}"`, path: ["projects", pid, "gitRemotes", i] });
1495
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `remote "${r}" already registered to project "${prev}"`, path: ["projects", pid, "gitRemotes", i] });
1413
1496
  }
1414
1497
  seen.set(r, pid);
1415
1498
  }
@@ -1420,7 +1503,7 @@ export const ProjectsConfig = z
1420
1503
  // Unified shape = slots × topology × rounds × finalizer. COMPLETELY separate from rosters (拍板): a slot
1421
1504
  // member's `modelRef` points at the models CATALOG (`ModelEntry.name`, the key — NOT the gateway modelId,
1422
1505
  // NOT a roster); roster churn never touches a template. Execution lives service-side ("collab spec →
1423
- // workflow/team run" translation layer); the center owns the template CONFIG only.
1506
+ // workflow/team run" translation layer); web-admin owns the template CONFIG only.
1424
1507
  export const CollabTopology = z.enum(["star", "ring", "layered", "pair", "blackboard"]);
1425
1508
  export const CollabFinalizerType = z.enum(["judge", "vote", "fuse", "verify", "summarize", "none"]);
1426
1509
  export const CollabEarlyStop = z.enum(["consensus", "stability", "judge_call", "verify_pass"]);
@@ -1444,7 +1527,7 @@ const CollabSlot = z.object({
1444
1527
  rolePrompt: z.string().max(2000).optional(),
1445
1528
  /** 1 = exactly one member; "N" = the slot accepts multiple members (异构评审团). */
1446
1529
  cardinality: z.union([z.literal(1), z.literal("N")]).default(1),
1447
- members: z.array(CollabMember).default([]),
1530
+ members: z.array(CollabMember).prefault([]),
1448
1531
  /** Tool names available to this slot's members (the old team `scenario` binding migrates here). */
1449
1532
  tools: z.array(z.string().min(1)).optional(),
1450
1533
  });
@@ -1457,7 +1540,7 @@ const CollabTemplate = z.object({
1457
1540
  slots: z.array(CollabSlot).min(1),
1458
1541
  /** `max` is ALWAYS the safety cap (blackboard topology: event/round cap — required, 防跑飞);
1459
1542
  * `earlyStop: "verify_pass"` carries the architect pair's "verified → stop" loop. */
1460
- rounds: z.object({ max: z.number().int().positive(), earlyStop: CollabEarlyStop.optional() }),
1543
+ rounds: z.object({ max: INTEGER.positive(), earlyStop: CollabEarlyStop.optional() }),
1461
1544
  finalizer: z.object({
1462
1545
  type: CollabFinalizerType,
1463
1546
  /** Slot id the finalizer anchors to (e.g. the judge slot). Meaningless for type "none". */
@@ -1468,10 +1551,10 @@ const CollabTemplate = z.object({
1468
1551
  kind: z.enum(["transcript", "blackboard"]).default("transcript"),
1469
1552
  persistence: CollabPersistence.default("session"),
1470
1553
  })
1471
- .default({}),
1554
+ .prefault({}),
1472
1555
  /** 多模型烧钱必须有闸 (拍板): the budget object is REQUIRED and at least one axis must be set. */
1473
1556
  budget: z.object({
1474
- maxTokens: z.number().int().positive().optional(),
1557
+ maxTokens: INTEGER.positive().optional(),
1475
1558
  maxCost: z.number().finite().positive().optional(),
1476
1559
  }),
1477
1560
  enabled: z.boolean().default(true),
@@ -1480,34 +1563,34 @@ const CollabTemplate = z.object({
1480
1563
  const slotIds = new Set();
1481
1564
  t.slots.forEach((s, i) => {
1482
1565
  if (slotIds.has(s.id))
1483
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate slot id "${s.id}"`, path: ["slots", i, "id"] });
1566
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate slot id "${s.id}"`, path: ["slots", i, "id"] });
1484
1567
  slotIds.add(s.id);
1485
1568
  if (s.cardinality === 1 && s.members.length > 1)
1486
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `slot "${s.id}" has cardinality 1 but ${s.members.length} members`, path: ["slots", i, "members"] });
1569
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `slot "${s.id}" has cardinality 1 but ${s.members.length} members`, path: ["slots", i, "members"] });
1487
1570
  });
1488
1571
  if (t.finalizer.type === "none") {
1489
1572
  if (t.finalizer.slot !== undefined)
1490
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `finalizer type "none" must not anchor a slot`, path: ["finalizer", "slot"] });
1573
+ ctx.addIssue({ code: "custom", message: `finalizer type "none" must not anchor a slot`, path: ["finalizer", "slot"] });
1491
1574
  }
1492
1575
  else if (t.finalizer.slot !== undefined && !slotIds.has(t.finalizer.slot)) {
1493
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `finalizer.slot "${t.finalizer.slot}" is not a slot id of this template`, path: ["finalizer", "slot"] });
1576
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `finalizer.slot "${t.finalizer.slot}" is not a slot id of this template`, path: ["finalizer", "slot"] });
1494
1577
  }
1495
1578
  if (t.budget.maxTokens === undefined && t.budget.maxCost === undefined) {
1496
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `template "${t.id}": budget needs at least one of maxTokens/maxCost (多模型烧钱必须有闸)`, path: ["budget"] });
1579
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `template "${t.id}": budget needs at least one of maxTokens/maxCost (多模型烧钱必须有闸)`, path: ["budget"] });
1497
1580
  }
1498
1581
  });
1499
- export const CollabConfig = z.object({ templates: z.array(CollabTemplate).default([]) }).superRefine((cfg, ctx) => {
1582
+ export const CollabConfig = z.object({ templates: z.array(CollabTemplate).prefault([]) }).superRefine((cfg, ctx) => {
1500
1583
  // `id` is the template's reference key — dedup like every other keyed domain (two same-id templates
1501
1584
  // shipping → the resolver silently takes the first; same failure class the old teams dedup fixed).
1502
1585
  const seen = new Set();
1503
1586
  cfg.templates.forEach((t, i) => {
1504
1587
  if (seen.has(t.id))
1505
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate collab template "${t.id}" — the template key must be unique`, path: ["templates", i, "id"] });
1588
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate collab template "${t.id}" — the template key must be unique`, path: ["templates", i, "id"] });
1506
1589
  seen.add(t.id);
1507
1590
  });
1508
1591
  });
1509
1592
  // ── Domain: workers (声明式编排 — service design/22, the orchestrator design note) ──────
1510
- // 每个 worker = 一个 worker 服务实例的声明(一份 env profile)。sema-registry 存 DESIRED(逻辑参数
1593
+ // 每个 worker = 一个 worker 服务实例的声明(一份 env profile)。web-admin 存 DESIRED(逻辑参数
1511
1594
  // + secret 的 env-NAME 引用,**绝不存 secret 值**);一个 per-host reconciler 拉取后在宿主机本地解析
1512
1595
  // secret → 渲染 docker compose → up/down 收敛。worker spec ↔ service env 的精确映射见 worker-env.ts。
1513
1596
  //
@@ -1520,7 +1603,7 @@ const DURATION = z.string().regex(/^\d+(s|m|h|d)$/, 'duration like "30m", "2h",
1520
1603
  // 生命周期 (design/25 §3 决策 3/4)。owner/purpose 用于配额 + 级联清理 + 审计;dependsOn 防级联雪崩。
1521
1604
  export const WorkerLifecycle = z.object({
1522
1605
  kind: z.enum(["persistent", "ephemeral"]).default("persistent"),
1523
- /** ephemeral: 绝对存活时长,自 sema-registry 首次见到起算(防时钟漂移),到期硬移除。 */
1606
+ /** ephemeral: 绝对存活时长,自 web-admin 首次见到起算(防时钟漂移),到期硬移除。 */
1524
1607
  ttl: DURATION.optional(),
1525
1608
  /** 无活动多久 → 告警(MVP 只告警,不自动 disable)。数据源 = 反代 metrics。 */
1526
1609
  idleTimeout: DURATION.optional(),
@@ -1530,6 +1613,9 @@ export const WorkerLifecycle = z.object({
1530
1613
  /** 依赖的其它 worker name —— sweeper 移除前查依赖,不孤儿化仍被依赖的 worker。 */
1531
1614
  dependsOn: z.array(z.string().min(1)).optional(),
1532
1615
  });
1616
+ /** 7.0.0 删除的 worker 字段(6.x 存储形)→ 它的新位。墓碑字段写在每个旧键原来所在的对象里({@link retiredKey}):在场即拒、拒句点名
1617
+ * 一次性迁移 SS-M4 —— 不让一行旧数据以「什么都没声明」的样子过闸(zod 会静默剥掉不认识的键)。 */
1618
+ const sixXField = (now) => ({ retiredIn: "7.0.0", now, migration: "SS-M4" });
1533
1619
  const WorkerSecretRefs = z.object({
1534
1620
  /** NAME of the host env var holding the model API key → service MODEL_API_KEY (或 anthropic 时 ANTHROPIC_API_KEY). */
1535
1621
  apiKeyEnv: ENV_NAME,
@@ -1540,6 +1626,7 @@ const WorkerSecretRefs = z.object({
1540
1626
  /** 7.0.0:SQL 店的口令 → `MYSQL_PASSWORD` / `PG_PASSWORD`(渲染器按 `runtime.store.backend` 选名族)。
1541
1627
  * 在场 ⇔ `runtime.store.backend` ∈ {mysql, pg}(WorkerSpec 层判,与 `runtime.store.db` 同一条规则)。取代 6.x 的 `tidbPasswordEnv`。 */
1542
1628
  dbPasswordEnv: ENV_NAME.optional(),
1629
+ tidbPasswordEnv: retiredKey("secretRefs.tidbPasswordEnv", sixXField("secretRefs.dbPasswordEnv")),
1543
1630
  });
1544
1631
  /**
1545
1632
  * 每个**保留** env 名 → WorkerSpec 里产出它的那个结构字段(唯一写位)。`env` / `secretEnv` 逃生口拒这些名,拒因指路到这里写的字段
@@ -1552,12 +1639,26 @@ const WorkerSecretRefs = z.object({
1552
1639
  * 7.0.0 增删(M-R2 结构块):
1553
1640
  * · **加** `REMOTE_EXEC` / `REQUIRE_EXECUTION_LANE` + 车道表({@link EXECUTION_LANES})里**全部**车道 env 名 —— 车道有了结构位
1554
1641
  * `runtime.execution`,逃生口再能设车道就是两把尺。这是对 0.1.30「执行基座键不保留」的**反转**:当年不保留是因为没有结构位
1555
- * (活舰队 direct = k8s、canary = e2b 只能在 `worker.env` 里设);存量行由一次性迁移 `migrateWorkersDomain` 搬进结构块。
1642
+ * (活舰队 direct = k8s、canary = e2b 只能在 `worker.env` 里设);存量行由一次性迁移 SS-M4 搬进结构块。
1556
1643
  * 别的域也读的名(`MINIO_*`、`LOG_LEVEL`、`REMOTE_EXEC_HOME_DIR`)不在车道表里,照旧不保留。
1557
1644
  * · **加** `DB_BACKEND` / `MEMORY_ENGINE` / `MEMORY_ENGINE_BACKEND` —— `runtime.store` 产出它们。
1558
- * · **删** `MEMORY_BACKEND`(引擎从不读的死名,渲染器不再产出)与 `TIDB_*` 族(引擎的墓碑名,见之拒启;结构字段不再产出它们 ——
1559
- * 墓碑名由引擎导出的墓碑表把关,不是本表的职责)。MYSQL_* / PG_* 两族归 `runtime.store.db` + `secretRefs.dbPasswordEnv`。
1645
+ * · **删** `MEMORY_BACKEND`(引擎从不读的死名,渲染器不再产出)与 `TIDB_*` 族(引擎的墓碑名,见之拒启;结构字段不再产出它们)。
1646
+ * MYSQL_* / PG_* 两族归 `runtime.store.db` + `secretRefs.dbPasswordEnv`。
1647
+ *
1648
+ * `env` / `secretEnv` 的写闸问**两张表**,各答一个问题:本表答「这个现名归哪个结构字段」(拒句指路到那个字段);墓碑表 `RETIRED_ENVS`
1649
+ * (`./retired-envs`)答「这个旧名已退役」(拒句就是墓碑句 `retiredEnvRefusal` 本身,引擎起服、壳、web-admin 读同一张表同一句)。墓碑名不进本表。
1650
+ *
1651
+ * 11.0.0:本表与 `RETIRED_ENVS` 的旧名集合零交集 —— `ANTHROPIC_BASEURL`(墓碑名)换成现名 `ANTHROPIC_BASE_URL`;渲染器写的 web-admin 连接三键
1652
+ * 入表,同 fleet 成员身份两键的「渲染器写、不可配」。**规则一条:渲染器写的名字是保留名**(运维不可在 `env` / `secretEnv` 里自写)。改名窗里渲染器
1653
+ * 按目标引擎版本写名,对还读上一代名的引擎写的是 `SEMA_REGISTRY_URL` / `SEMA_REGISTRY_WORKER` / `SEMA_REGISTRY_TOKEN`,所以这三名与现名三键同一组、
1654
+ * 同一句拒(指路同一处)。老三名在引擎换读现名的那一版随之搬去墓碑表(同一提交);保留表与墓碑表零交集的钉会逼这一步。
1560
1655
  */
1656
+ /** web-admin 连接三键的唯一写位(渲染器;没有 WorkerSpec 字段)—— 现名与上一代名指路同一句。 */
1657
+ const WEB_ADMIN_CONNECTION_SEAT = {
1658
+ url: "(set by the config renderer from the web-admin base URL — not configurable)",
1659
+ worker: "(set by the config renderer from the worker's name — not configurable)",
1660
+ token: "(set by the config renderer from the worker's pull token — not configurable)",
1661
+ };
1561
1662
  const RESERVED_ENV_FIELD = Object.freeze({
1562
1663
  PORT: "(fixed at 8090 — not configurable)", MODEL_PROVIDER: "model.provider", MODEL_ID: "model.modelId",
1563
1664
  MODEL_REASONING: "model.reasoning", MODEL_CHEAP_ID: "model.cheapId", MODEL_MAX_TOKENS: "model.maxTokens",
@@ -1565,7 +1666,7 @@ const RESERVED_ENV_FIELD = Object.freeze({
1565
1666
  MODEL_COST_CACHE_READ: "model.cost.cacheRead", MODEL_COST_CACHE_WRITE: "model.cost.cacheWrite",
1566
1667
  MAX_PRINCIPAL_COST_USD: "runtime.maxPrincipalCostUsd", REQUIRE_PRINCIPAL: "runtime.requirePrincipal", RATE_LIMIT_RPM: "runtime.rateLimitRpm",
1567
1668
  DEFAULT_SCENARIO: "runtime.scenario", GIT_API_BASEURL: "coords.gitApiBaseUrl", MODEL_GATEWAY_BASEURL: "model.gatewayBaseUrl",
1568
- ANTHROPIC_BASEURL: "model.gatewayBaseUrl", MODEL_API_KEY: "secretRefs.apiKeyEnv", ANTHROPIC_API_KEY: "secretRefs.apiKeyEnv",
1669
+ ANTHROPIC_BASE_URL: "model.gatewayBaseUrl", MODEL_API_KEY: "secretRefs.apiKeyEnv", ANTHROPIC_API_KEY: "secretRefs.apiKeyEnv",
1569
1670
  SERVICE_AUTH_TOKEN: "secretRefs.serviceAuthTokenEnv", GIT_API_TOKEN: "secretRefs.gitTokenEnv",
1570
1671
  // ── runtime.store(7.0.0)
1571
1672
  DB_BACKEND: "runtime.store.backend", SESSION_BACKEND: "runtime.store.session",
@@ -1579,6 +1680,11 @@ const RESERVED_ENV_FIELD = Object.freeze({
1579
1680
  // ── fleet 成员身份两键:渲染器按该 worker 的代际写 generation(引擎读回并回声成 EngineSignal.generation)、按该 worker 令牌绑定的 scope 写 scope(引擎读作自身身份的 scope)—— 没有 WorkerSpec 字段能写它们
1580
1681
  [FLEET_GENERATION_ENV]: "(set by the config renderer from the worker's generation — not configurable)",
1581
1682
  [FLEET_SCOPE_ENV]: "(set by the config renderer from the worker's scope — not configurable)",
1683
+ // ── web-admin 连接三键:渲染器给每台 worker 写 web-admin 根 URL、worker 名与该 worker 的拉取令牌 —— 没有 WorkerSpec 字段能写它们;
1684
+ // 现名与渲染器对老引擎写的上一代名同一组(见表头注)
1685
+ [WEB_ADMIN_ENV.url]: WEB_ADMIN_CONNECTION_SEAT.url, SEMA_REGISTRY_URL: WEB_ADMIN_CONNECTION_SEAT.url,
1686
+ [WEB_ADMIN_ENV.worker]: WEB_ADMIN_CONNECTION_SEAT.worker, SEMA_REGISTRY_WORKER: WEB_ADMIN_CONNECTION_SEAT.worker,
1687
+ [WEB_ADMIN_ENV.token]: WEB_ADMIN_CONNECTION_SEAT.token, SEMA_REGISTRY_TOKEN: WEB_ADMIN_CONNECTION_SEAT.token,
1582
1688
  REQUIRE_EXECUTION_LANE: "runtime.execution.lane (the renderer emits REQUIRE_EXECUTION_LANE=true for every declared lane)",
1583
1689
  ...Object.fromEntries([...LANE_ENV_OWNER].map(([name, owner]) => [name, laneEnvSeat(name, owner)])),
1584
1690
  });
@@ -1588,14 +1694,20 @@ export const RESERVED_WORKER_ENV = new Set(Object.keys(RESERVED_ENV_FIELD));
1588
1694
  /**
1589
1695
  * 保留名被 `env` / `secretEnv` 逃生口设置时的拒因:点名键 + 它的唯一写位。句尾恒是「set it via <字段>」(下游按它抽归属字段,不动)。
1590
1696
  * **迁移指路只有一条规则(7.0.1)**:这个键「旧形 ⇒ 迁移能搬」({@link isMigratableEnvKey},与迁移自己识别的键集同一只谓词)就在键名后
1591
- * 带 {@link MIGRATION_SIGNPOST};搬不了的保留名(`PORT` / `MODEL_*` / `MYSQL_*` …)不带 —— 指一条跑了也没用的命令比不指更糟。
1697
+ * 带同一句迁移指路(一次性迁移 SS-M4,`migrationSignpost`);搬不了的保留名(`PORT` / `MODEL_*` / `MYSQL_*` …)不带 —— 指一条跑了也没用的迁移比不指更糟。
1592
1698
  */
1593
- function reservedKeyMessage(slot, key) {
1594
- const signpost = isMigratableEnvKey(key) ? ` (${MIGRATION_SIGNPOST})` : "";
1595
- return `${slot} cannot set reserved key "${key}"${signpost} — set it via ${RESERVED_ENV_FIELD[key]}`;
1699
+ function reservedKeyIssue(slot, key) {
1700
+ const signpost = isMigratableEnvKey(key) ? " (" + migrationSignpost("SS-M4", "7.0.0") + ")" : "";
1701
+ return buildQuotedIssue `${fixed(slot)} cannot set reserved key "${key}"${fixed(signpost)} — set it via ${fixed(RESERVED_ENV_FIELD[key])}`;
1702
+ }
1703
+ /** 逃生口里有墓碑表的旧名 ⇒ 一条 issue,句子是墓碑句本身(`retiredEnvRefusal` —— 唯一铸点,本包不另拼;句里只有表里的名,不回显值)。 */
1704
+ function retiredEnvIssue(rec, ctx) {
1705
+ const refusal = retiredEnvRefusal(rec);
1706
+ if (refusal !== undefined)
1707
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `${fixed(refusal)}` });
1596
1708
  }
1597
1709
  // ── 7.0.0 M-R2:`runtime.execution` / `runtime.store` 两个结构块 ─────────────────────────────────────────────
1598
- // 词表只有一个正本(`execution-lanes.ts` 的导出表):本包写闸(`WorkerSpec.safeParse`)、中心渲染器、引擎起服三处读同一张表。
1710
+ // 词表只有一个正本(`execution-lanes.ts` 的导出表):本包写闸(`WorkerSpec.safeParse`)、web-admin 渲染器、引擎起服三处读同一张表。
1599
1711
  // 拒因都在 zod issue 里(`path` 指到要改的字段、`message` 带出路)—— schema 本身是唯一判据,不另导出拒因函数。
1600
1712
  /** 一个非密字面值(车道坐标 / SQL 坐标):≤2048,不像密钥(与 `env` 逃生口同一条判据 —— 密钥走 env-NAME 引用)。 */
1601
1713
  const nonSecretLiteral = (minLength) => z
@@ -1613,47 +1725,47 @@ const nonSecretLiteral = (minLength) => z
1613
1725
  */
1614
1726
  const ExecutionBlock = z
1615
1727
  .object({
1616
- lane: z.enum(EXECUTION_LANE_WORDS),
1617
- coords: z.record(ENV_NAME, nonSecretLiteral(0)).default({}),
1618
- secretRefs: z.record(ENV_NAME, ENV_NAME).default({}),
1728
+ lane: z.enum(EXECUTION_LANE_WORDS, { error: (iss) => retiredLaneWordRefusal(iss.input) }), // a retired lane word gets its way out
1729
+ coords: z.record(ENV_NAME, nonSecretLiteral(0)).prefault({}),
1730
+ secretRefs: z.record(ENV_NAME, ENV_NAME).prefault({}),
1619
1731
  })
1620
1732
  .superRefine((x, ctx) => {
1621
1733
  const row = laneRowOf(x.lane);
1622
- const issue = (slot, key, message) => ctx.addIssue({ code: z.ZodIssueCode.custom, path: [slot, key], message });
1734
+ const issue = (slot, key, q) => ctx.addIssue({ code: "custom", path: [slot, key], ...q });
1623
1735
  const owned = (secret) => (secret ? [...row.requiredSecretEnv, ...row.optionalSecretEnv] : [...row.requiredEnv, ...row.optionalEnv]);
1624
1736
  const judgeKey = (slot, key) => {
1625
1737
  const wantSecret = slot === "secretRefs";
1626
1738
  const owner = LANE_ENV_OWNER.get(key);
1627
1739
  if (owner === undefined) {
1628
1740
  const allowed = owned(wantSecret);
1629
- issue(slot, key, `${key} is not ${wantSecret ? "a secret" : "a coordinate"} of any execution lane — lane "${x.lane}" takes ${slot} ${allowed.length > 0 ? allowed.join(", ") : "(none)"}`);
1741
+ issue(slot, key, buildQuotedIssue `${key} is not ${fixed(wantSecret ? "a secret" : "a coordinate")} of any execution lane — lane "${x.lane}" takes ${fixed(slot)} ${fixed(allowed.length > 0 ? allowed.join(", ") : "(none)")}`);
1630
1742
  return false;
1631
1743
  }
1632
1744
  if (owner.lane !== x.lane) {
1633
- issue(slot, key, `${key} belongs to lane "${owner.lane}", not "${x.lane}" — remove it, or set runtime.execution.lane to "${owner.lane}"`);
1745
+ issue(slot, key, buildQuotedIssue `${key} belongs to lane "${fixed(owner.lane)}", not "${x.lane}" — remove it, or set runtime.execution.lane to "${fixed(owner.lane)}"`);
1634
1746
  return false;
1635
1747
  }
1636
1748
  if (owner.secret !== wantSecret) {
1637
1749
  issue(slot, key, owner.secret
1638
- ? `${key} is a secret of lane "${x.lane}" — put it in runtime.execution.secretRefs as an env-NAME (never a value in coords)`
1639
- : `${key} is a non-secret coordinate of lane "${x.lane}" — write its value in runtime.execution.coords`);
1750
+ ? buildQuotedIssue `${key} is a secret of lane "${x.lane}" — put it in runtime.execution.secretRefs as an env-NAME (never a value in coords)`
1751
+ : buildQuotedIssue `${key} is a non-secret coordinate of lane "${x.lane}" — write its value in runtime.execution.coords`);
1640
1752
  return false;
1641
1753
  }
1642
1754
  return true;
1643
1755
  };
1644
1756
  for (const [key, value] of Object.entries(x.coords)) {
1645
1757
  if (judgeKey("coords", key) && value === "" && LANE_ENV_OWNER.get(key)?.required === true) {
1646
- issue("coords", key, `required coordinate ${key} of lane "${x.lane}" is empty — the engine reads an empty value as unset and refuses to start`);
1758
+ issue("coords", key, buildQuotedIssue `required coordinate ${key} of lane "${x.lane}" is empty — the engine reads an empty value as unset and refuses to start`);
1647
1759
  }
1648
1760
  }
1649
1761
  for (const key of Object.keys(x.secretRefs))
1650
1762
  judgeKey("secretRefs", key);
1651
1763
  for (const key of row.requiredEnv)
1652
1764
  if (!Object.hasOwn(x.coords, key))
1653
- issue("coords", key, `lane "${x.lane}" requires coords.${key} (the engine refuses to start without it)`);
1765
+ issue("coords", key, buildQuotedIssue `lane "${x.lane}" requires coords.${key} (the engine refuses to start without it)`);
1654
1766
  for (const key of row.requiredSecretEnv)
1655
1767
  if (!Object.hasOwn(x.secretRefs, key))
1656
- issue("secretRefs", key, `lane "${x.lane}" requires secretRefs.${key} — the host env-NAME holding it`);
1768
+ issue("secretRefs", key, buildQuotedIssue `lane "${x.lane}" requires secretRefs.${key} — the host env-NAME holding it`);
1657
1769
  });
1658
1770
  /** `MEMORY_ENGINE` 的两个词(引擎目录行的 `enumValues`;引擎还认 true/false/1/0… 两族同义词,结构位只收规范词)。 */
1659
1771
  const MEMORY_ENGINE_SWITCH = ["on", "off"];
@@ -1675,7 +1787,7 @@ const StoreBlock = z.object({
1675
1787
  engine: z.enum(MEMORY_ENGINE_SWITCH).default("on"),
1676
1788
  backend: z.enum(MEMORY_ENGINE_BACKENDS).default("file"),
1677
1789
  })
1678
- .default({}),
1790
+ .prefault({}),
1679
1791
  /** SQL 服务器坐标(中立,一份;名族由 `backend` 决定)。 */
1680
1792
  db: z
1681
1793
  .object({
@@ -1686,40 +1798,8 @@ const StoreBlock = z.object({
1686
1798
  })
1687
1799
  .optional(),
1688
1800
  });
1689
- /** 7.0.0 删除的字段 → 它的新位。**迁移窗口代码**:与 `migrateWorkersDomain` 同窗删除(只为未迁移的旧形读到即响亮拒,而不是被
1690
- * zod 静默剥掉 —— 剥掉的话,一行旧数据会以「什么都没声明」的样子过闸)。 */
1691
- const RETIRED_WORKER_FIELDS = [
1692
- { parent: "runtime", key: "sessionBackend", now: "runtime.store.session" },
1693
- { parent: "runtime", key: "memoryBackend", now: "runtime.store.memory — the old field never reached the engine" },
1694
- { parent: "runtime", key: "db", now: "runtime.store.db.database" },
1695
- { parent: "coords", key: "tidbHost", now: "runtime.store.db.host" },
1696
- { parent: "coords", key: "tidbPort", now: "runtime.store.db.port" },
1697
- { parent: "coords", key: "tidbUser", now: "runtime.store.db.user" },
1698
- { parent: "secretRefs", key: "tidbPasswordEnv", now: "secretRefs.dbPasswordEnv" },
1699
- ];
1700
- /** 一个对象的**自有数据属性**值(不触发访问器;不是普通对象 ⇒ undefined)。 */
1701
- function ownDataValue(obj, key) {
1702
- if (obj === null || typeof obj !== "object" || Array.isArray(obj))
1703
- return undefined;
1704
- const d = Object.getOwnPropertyDescriptor(obj, key);
1705
- return d !== undefined && "value" in d ? d.value : undefined;
1706
- }
1707
- function retiredWorkerFieldIssues(raw, ctx) {
1708
- for (const { parent, key, now } of RETIRED_WORKER_FIELDS) {
1709
- const container = ownDataValue(raw, parent);
1710
- if (container !== null && typeof container === "object" && !Array.isArray(container) && Object.hasOwn(container, key)) {
1711
- ctx.addIssue({
1712
- code: z.ZodIssueCode.custom,
1713
- path: [parent, key],
1714
- message: `${parent}.${key} was removed in 7.0.0 (now ${now}) — ${MIGRATION_SIGNPOST}`,
1715
- });
1716
- }
1717
- }
1718
- return raw;
1719
- }
1720
- /** 一台 worker 的名字(唯一 → 容器名 / compose project / 缺省路由键)—— **唯一**的名字判据。中心在租约 / 用量 /
1721
- * discovery `?worker=` 等面上校验外部传来的 worker 名时读它(不抄窄正则)。7.0.0 起单独导出:`WorkerSpec` 不再是普通
1722
- * 对象 schema(预处理 + 跨字段判据),`WorkerSpec.shape.name` 不复存在。 */
1801
+ /** 一台 worker 的名字(唯一 → 容器名 / compose project / 缺省路由键)—— **唯一**的名字判据。web-admin 在租约 / 用量 /
1802
+ * discovery `?worker=` 等面上校验外部传来的 worker 名时读它(不抄窄正则)。 */
1723
1803
  export const WORKER_NAME = z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces");
1724
1804
  const WorkerSpecObject = z.object({
1725
1805
  /** 唯一 → 容器名 / compose project. */
@@ -1739,7 +1819,7 @@ const WorkerSpecObject = z.object({
1739
1819
  * Hash-INCLUDED → a change re-applies the Deployment (k8s scales IN PLACE via SSA, no pod destruction). The
1740
1820
  * docker backend is single-container: it warns and runs 1 if this is >1. Capped at 100 (resource-exhaustion
1741
1821
  * guard — a typo'd 1e6 mustn't try to schedule a million pods; raise the cap if a real workload needs it). */
1742
- replicas: z.number().int().positive().max(100).optional(),
1822
+ replicas: INTEGER.positive().max(100).optional(),
1743
1823
  /** AUTOSCALING (k8s backend, HPA) — declare bounds and let the cluster's HorizontalPodAutoscaler own the
1744
1824
  * replica count (autoscaling/v2, CPU utilization; prove-before-scale 解禁 2026-06-13). Mutually exclusive
1745
1825
  * with `replicas` (WorkersConfig rejects both — two owners of the same knob WILL fight). The orchestrator
@@ -1750,10 +1830,10 @@ const WorkerSpecObject = z.object({
1750
1830
  * single-container, warns + ignores. Hash-INCLUDED; absent → no HPA, today's behaviour (zero churn). */
1751
1831
  autoscaling: z
1752
1832
  .object({
1753
- minReplicas: z.number().int().positive().max(100),
1754
- maxReplicas: z.number().int().positive().max(100),
1833
+ minReplicas: INTEGER.positive().max(100),
1834
+ maxReplicas: INTEGER.positive().max(100),
1755
1835
  /** Target average CPU utilization %, vs resources.requests.cpu. Absent → backend default 80. */
1756
- targetCpuPercent: z.number().int().min(10).max(95).optional(),
1836
+ targetCpuPercent: INTEGER.min(10).max(95).optional(),
1757
1837
  })
1758
1838
  .refine((a) => a.maxReplicas >= a.minReplicas, { message: "maxReplicas must be >= minReplicas" })
1759
1839
  .optional(),
@@ -1770,19 +1850,19 @@ const WorkerSpecObject = z.object({
1770
1850
  * hash-INCLUDED like every spec field — absent → contract defaults (zero churn on existing fleets). */
1771
1851
  fleet: z
1772
1852
  .object({
1773
- /** Per-worker dispatch policy consumed by center `/api/dispatch` (D3: the gateway hop is optional;
1853
+ /** Per-worker dispatch policy consumed by web-admin `/api/dispatch` (D3: the gateway hop is optional;
1774
1854
  * only affects traffic routed through it). Absent → contract defaults (round-robin, affinity on). */
1775
1855
  dispatch: DispatchPolicy.optional(),
1776
1856
  /** D1 per-worker drain-grace override → delivered to the container as env `DRAIN_GRACE_MS`
1777
1857
  * (worker-side default 600s, floor 10s). */
1778
- drainGraceMs: z.number().int().positive().optional(),
1858
+ drainGraceMs: INTEGER.positive().optional(),
1779
1859
  })
1780
1860
  .optional(),
1781
1861
  // `model` DELETED in 0.7.0 (EXPERT-REDESIGN §3 拍板 + E3 迁移闭环 — a2 存量 5 worker 已迁移目录+roster
1782
1862
  // 标记,E3):模型/网关配置单一真源=models 目录,worker 经 `roster` 引用;主/cheap boot env
1783
1863
  // 归宿=roster.primaryModel/cheapModel 标记(0.6.3,service 已承接)。pre-0.7 存量 JSON 里的
1784
1864
  // `model` 键被 parse 静默剥除(zod strips unknown)→ workerSpecHash 变化=一次性重建;迁移工具输入=
1785
- // 冻结的 LegacyWorkerModel(migrate.ts),必须在升级 0.7 前跑完(a2 已闭环,E3)。
1865
+ // 冻结的旧 worker.model 形(0.5 → 0.7 迁移工具,11.0.0 随 `./migrate` 出口删除),必须在升级 0.7 前跑完(a2 已闭环,E3)。
1786
1866
  /** 运行时 (非 secret). 映射到 service REQUIRE_PRINCIPAL / RATE_LIMIT_RPM / … 与两个结构块(7.0.0)。 */
1787
1867
  runtime: z.object({
1788
1868
  /** 7.0.0 执行车道块(见 `ExecutionBlock`)。enabled 的 worker 必填(WorkerSpec 层判);停用的可缺。 */
@@ -1791,52 +1871,61 @@ const WorkerSpecObject = z.object({
1791
1871
  store: StoreBlock.optional(),
1792
1872
  scenario: z.string().optional(), // DEFAULT_SCENARIO
1793
1873
  requirePrincipal: z.boolean().default(false),
1794
- rateLimitRpm: z.number().int().nonnegative().default(0),
1874
+ rateLimitRpm: INTEGER.nonnegative().default(0),
1795
1875
  /** Per-principal cumulative cost CAP (USD) → MAX_PRINCIPAL_COST_USD → the service's cost gate ENFORCEMENT
1796
1876
  * (over-cap → 429). Pairs with model.cost (which only ATTRIBUTES cost). ABSENT → no env → the service
1797
1877
  * defaults to 0 = off (today's behaviour); EXPLICIT 0 emits "0" = block-all and is HASH-DISTINCT from
1798
1878
  * absent (so the guard is `!= null`, not `> 0` — the two carry different caller intent). Optional + NO
1799
1879
  * default → absent never materialises → no spec-hash churn. Hash-included (read at boot → cap change recreates). */
1800
1880
  maxPrincipalCostUsd: z.number().finite().nonnegative().optional(),
1881
+ sessionBackend: retiredKey("runtime.sessionBackend", sixXField("runtime.store.session")),
1882
+ memoryBackend: retiredKey("runtime.memoryBackend", sixXField("runtime.store.memory — the old field never reached the engine")),
1883
+ db: retiredKey("runtime.db", sixXField("runtime.store.db.database")),
1801
1884
  }),
1802
- /** 物理坐标 (非 secret). 映射到 GIT_API_BASEURL。7.0.0 起 SQL 坐标在 `runtime.store.db`(`tidb*` 三键删除)。 */
1885
+ /** 物理坐标 (非 secret). 映射到 GIT_API_BASEURL。7.0.0 起 SQL 坐标在 `runtime.store.db`(`tidb*` 三键删除,墓碑在下)。 */
1803
1886
  coords: z
1804
1887
  .object({
1805
1888
  gitApiBaseUrl: NON_SECRET_URL.optional().or(z.literal("")),
1889
+ tidbHost: retiredKey("coords.tidbHost", sixXField("runtime.store.db.host")),
1890
+ tidbPort: retiredKey("coords.tidbPort", sixXField("runtime.store.db.port")),
1891
+ tidbUser: retiredKey("coords.tidbUser", sixXField("runtime.store.db.user")),
1806
1892
  })
1807
- .default({}),
1893
+ .prefault({}),
1808
1894
  secretRefs: WorkerSecretRefs,
1809
1895
  /**
1810
1896
  * 通用 env 透传(逃生口)——给**没有专属结构化字段**的 service env 用,让任意 worker 声明自己的 app 专属变量
1811
- * (如 OA 的 `OA_API_BASEURL`),不必为每个变量改契约。值是**字面非密值**(≤2048,且拒绝明显的密钥串——密钥走
1897
+ * (如某个应用的 `MY_APP_BASEURL`),不必为每个变量改契约。值是**字面非密值**(≤2048,且拒绝明显的密钥串——密钥走
1812
1898
  * `secretEnv`)。**只能加结构化字段没产生的键**:`RESERVED_WORKER_ENV` 里的键(PORT、MODEL_x、gateway、auth、
1813
- * secret 名…)被拒,防 env 改路由/关鉴权/遮蔽密钥(council)。可覆盖非保留键(如 LOG_LEVEL)。hash-included(改它重建)。
1899
+ * secret 名…)被拒,防 env 改路由/关鉴权/遮蔽密钥(council);墓碑表 `RETIRED_ENVS` 里的旧名被拒(引擎见之拒启;空串 = 未设,同引擎)。
1900
+ * 可覆盖非保留键(如 LOG_LEVEL)。hash-included(改它重建)。
1814
1901
  */
1815
1902
  env: z
1816
1903
  .record(ENV_NAME, z.string().max(2048))
1817
1904
  .superRefine((rec, ctx) => {
1905
+ retiredEnvIssue(rec, ctx);
1818
1906
  for (const [k, v] of Object.entries(rec)) {
1819
1907
  if (RESERVED_WORKER_ENV.has(k))
1820
- ctx.addIssue({ code: "custom", message: reservedKeyMessage("env", k) });
1908
+ ctx.addIssue({ code: "custom", ...reservedKeyIssue("env", k) });
1821
1909
  // 0.1.32: NO name exemption — a PUBLIC key is allowed because SECRET_LITERAL_RE now only flags PRIVATE keys
1822
1910
  // (root fix), so a literal private key / token in env (ANY name, incl `*_PUBKEY`) is still rejected.
1823
1911
  if (containsSecretToken(v))
1824
- ctx.addIssue({ code: "custom", message: `env["${k}"] looks like a literal secret — put secrets in secretEnv (an env-NAME), never a value in env` }); // tokenized (mid-string too). R8
1912
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `env["${k}"] looks like a literal secret — put secrets in secretEnv (an env-NAME), never a value in env` }); // tokenized (mid-string too). R8
1825
1913
  }
1826
1914
  })
1827
1915
  .optional(),
1828
1916
  /**
1829
1917
  * 通用**密钥** env 透传:`{ service 的环境变量名 → 宿主机 env-NAME }`。编排器把宿主机 env-NAME 从它的 secret 源
1830
- * 解析成真值、经 secretKeyRef 注入——**sema-registry 只存名、绝不存值**(同 `secretRefs` 边界,值也是 `ENV_NAME`
1831
- * 故无法粘贴真值)。给 app 专属令牌用(如 `OA_SERVICE_TOKEN` → 某 env-名)。在 reconciler 里于 `env` 之后解析,
1832
- * 故密钥永不被 `env` 同名键遮蔽。`RESERVED_WORKER_ENV` 里的键被拒(那些结构化 secret 走 `secretRefs`)。
1918
+ * 解析成真值、经 secretKeyRef 注入——**web-admin 只存名、绝不存值**(同 `secretRefs` 边界,值也是 `ENV_NAME`
1919
+ * 故无法粘贴真值)。给 app 专属令牌用(如 `MY_APP_TOKEN` → 某 env-名)。在 reconciler 里于 `env` 之后解析,
1920
+ * 故密钥永不被 `env` 同名键遮蔽。`RESERVED_WORKER_ENV` 里的键被拒(那些结构化 secret 走 `secretRefs`);墓碑表 `RETIRED_ENVS` 里的旧名被拒。
1833
1921
  */
1834
1922
  secretEnv: z
1835
1923
  .record(ENV_NAME, ENV_NAME)
1836
1924
  .superRefine((rec, ctx) => {
1925
+ retiredEnvIssue(rec, ctx);
1837
1926
  for (const k of Object.keys(rec))
1838
1927
  if (RESERVED_WORKER_ENV.has(k))
1839
- ctx.addIssue({ code: "custom", message: reservedKeyMessage("secretEnv", k) });
1928
+ ctx.addIssue({ code: "custom", ...reservedKeyIssue("secretEnv", k) });
1840
1929
  })
1841
1930
  .optional(),
1842
1931
  /** Opaque recreate trigger (hash-INCLUDED): bump it to force the reconciler to recreate the worker — e.g. to
@@ -1845,19 +1934,19 @@ const WorkerSpecObject = z.object({
1845
1934
  recreateNonce: z.string().max(64).optional(),
1846
1935
  /**
1847
1936
  * 生命周期 (v2②, design/25 §3)。可选——不填 = `persistent`(默认安全,绝不被 GC 误杀)。
1848
- * **reconciler 不读这个字段**(保持纯收敛);TTL/idle 决策全在 sema-registry 侧 sweeper:到期就把 worker
1937
+ * **reconciler 不读这个字段**(保持纯收敛);TTL/idle 决策全在 web-admin 侧 sweeper:到期就把 worker
1849
1938
  * 从 desired 移除,reconciler 声明式自然 teardown。`ttl`/`idleTimeout` 用时长串("30m"/"2h"/"7d")。
1850
1939
  */
1851
1940
  lifecycle: WorkerLifecycle.optional(),
1852
1941
  /**
1853
- * 多主机调度 (可选, 非 breaking — reconciler 不读)。`auto:true` → sema-registry 的 placement 按容量/标签约束
1854
- * 给它挑一台 host 并写回具体 `host`(reconciler 仍按具体 host 认领,契约不变)。`requires` = host 必须具备的
1855
- * 全部标签(hosts 域声明)。不填 = 手动指定 host(运维自己负责)。
1942
+ * 多主机调度(可选)。`placement.auto` 判 `host` 是**钉**还是**提示**:`auto:false`(缺省)⇒ `host` 是钉 —— 运维指定的那一台,
1943
+ * 只在那台起;`auto:true` ⇒ web-admin 的排布按容量 / 标签约束挑一台写回 `host`,那是提示,排布可改挑。`requires` = host 必须具备的
1944
+ * 全部标签(hosts 域声明)。不加单独的「钉」字段:钉 / 提示已由 `placement.auto` 编码,再加一个字段 = 同一事实两处写。
1856
1945
  */
1857
1946
  placement: z
1858
1947
  .object({
1859
1948
  auto: z.boolean().default(false),
1860
- requires: z.array(z.string().min(1)).default([]),
1949
+ requires: z.array(z.string().min(1)).prefault([]),
1861
1950
  /** SOFT anti-affinity group: auto workers sharing this key are spread across hosts when possible
1862
1951
  * (preferred, never blocks placement — the scheduling design note). 不填 = 无 spread 约束。 */
1863
1952
  spread: z.string().min(1).optional(),
@@ -1865,7 +1954,7 @@ const WorkerSpecObject = z.object({
1865
1954
  .optional(),
1866
1955
  /**
1867
1956
  * per-worker roster 引用 (the multi-roster design note)。指向 `rosters` 域里一个命名 roster;该 worker 的 service 拉
1868
- * `GET /api/config/effective?worker=<name>` 时,sema-registry 用这个 roster 解析它专属的 models/roles/@白名单。
1957
+ * `GET /api/config/effective?worker=<name>` 时,web-admin 用这个 roster 解析它专属的 models/roles/@白名单。
1869
1958
  * 不填 → 该 worker 拿全局 models(向后兼容)。**不进 `workerSpecHash`**(改 roster 只让 service 重拉、不重建容器)。
1870
1959
  */
1871
1960
  roster: z.string().min(1).optional(),
@@ -1875,29 +1964,26 @@ const WorkerSpecObject = z.object({
1875
1964
  });
1876
1965
  /** 跨字段判据(G1 / G2 / host∧多副本 / store 四条)。每条一个 issue,`path` 指到要改的字段。 */
1877
1966
  function workerSpecIssues(w, ctx) {
1878
- const issue = (path, message) => ctx.addIssue({ code: z.ZodIssueCode.custom, path, message });
1967
+ const issue = (path, q) => ctx.addIssue({ code: "custom", path, ...q });
1879
1968
  const { execution, store, requirePrincipal } = w.runtime;
1880
1969
  // G1:enabled 的 worker 必须声明车道与店(停用的不渲染,可缺)。
1881
1970
  if (w.enabled && execution === undefined) {
1882
- issue(["runtime", "execution", "lane"], `an enabled worker must declare its execution lane — runtime.execution.lane: ${EXECUTION_LANE_WORDS.join(" | ")}. ` +
1883
- `Without one it would boot with no hands and still report tasks completed; there is no "no hands" lane — disable the worker instead`);
1971
+ issue(["runtime", "execution", "lane"], buildQuotedIssue `an enabled worker must declare its execution lane — runtime.execution.lane: ${fixed(EXECUTION_LANE_WORDS.join(" | "))}. Without one it would boot with no hands and still report tasks completed; there is no "no hands" lane — disable the worker instead`);
1884
1972
  }
1885
1973
  if (w.enabled && store === undefined) {
1886
- issue(["runtime", "store", "backend"], `an enabled worker must declare its store — runtime.store.backend: ${STORE_BACKENDS.join(" | ")}`);
1974
+ issue(["runtime", "store", "backend"], buildQuotedIssue `an enabled worker must declare its store — runtime.store.backend: ${fixed(STORE_BACKENDS.join(" | "))}`);
1887
1975
  }
1888
1976
  if (execution !== undefined) {
1889
1977
  // G2:多租户姿态不许跑不分租户的车道;出路按表现算(不手写第五份词表)。
1890
1978
  if (requirePrincipal && laneRowOf(execution.lane).tenantSeparation === "single-user-only") {
1891
1979
  const outlets = EXECUTION_LANES.filter((r) => r.tenantSeparation === "supported").map((r) => r.lane);
1892
- issue(["runtime", "execution", "lane"], `runtime.requirePrincipal=true (multi-tenant posture) with the "${execution.lane}" execution lane runs every tenant's Bash on one shared target — ` +
1893
- `this lane does not separate tenants; set runtime.execution.lane to ${outlets.join(" / ")}, or set runtime.requirePrincipal=false for a single-user worker`);
1980
+ issue(["runtime", "execution", "lane"], buildQuotedIssue `runtime.requirePrincipal=true (multi-tenant posture) with the "${execution.lane}" execution lane runs every tenant's Bash on one shared target — this lane does not separate tenants; set runtime.execution.lane to ${fixed(outlets.join(" / "))}, or set runtime.requirePrincipal=false for a single-user worker`);
1894
1981
  }
1895
1982
  // host ∧ 多副本:副本数有两个旋钮(replicas / autoscaling.maxReplicas,二者互斥),一条规则看「最多几个副本」。
1896
1983
  if (execution.lane === "host") {
1897
1984
  const [path, replicas] = w.autoscaling !== undefined ? [["autoscaling", "maxReplicas"], w.autoscaling.maxReplicas] : [["replicas"], w.replicas ?? 1];
1898
1985
  if (replicas > 1) {
1899
- issue(path, `the "host" execution lane runs tools on each replica's own filesystem — ${replicas} replicas would hold ${replicas} different workspaces behind one route, ` +
1900
- `so a session's next turn can land where its files are not. Keep a single replica, or choose a lane other than "host"`);
1986
+ issue(path, buildQuotedIssue `the "host" execution lane runs tools on each replica's own filesystem — ${replicas} replicas would hold ${replicas} different workspaces behind one route, so a session's next turn can land where its files are not. Keep a single replica, or choose a lane other than "host"`);
1901
1987
  }
1902
1988
  }
1903
1989
  }
@@ -1913,46 +1999,42 @@ function workerSpecIssues(w, ctx) {
1913
1999
  // 写闸判不了 —— 那一条只在起服拒。
1914
2000
  if (execution?.lane === "device" && store !== undefined && !sqlPoolOpens) {
1915
2001
  if (!sql) {
1916
- issue(["runtime", "store", "backend"], `the "device" execution lane keeps its device registry, session↔device binding and audit trail in SQL — store.backend "${store.backend}" has none ` +
1917
- `(the engine refuses to start: device-durable-store); set store.backend to mysql | pg`);
2002
+ issue(["runtime", "store", "backend"], buildQuotedIssue `the "device" execution lane keeps its device registry, session↔device binding and audit trail in SQL — store.backend "${store.backend}" has none (the engine refuses to start: device-durable-store); set store.backend to mysql | pg`);
1918
2003
  }
1919
2004
  else {
1920
- issue(["runtime", "store", "session"], `the "device" execution lane needs the SQL store actually built — store.session "memory" builds no SQL coordinates ` +
1921
- `(the engine refuses to start: device-durable-store); set store.session to auto | mysql`);
2005
+ issue(["runtime", "store", "session"], buildQuotedIssue `the "device" execution lane needs the SQL store actually built — store.session "memory" builds no SQL coordinates (the engine refuses to start: device-durable-store); set store.session to auto | mysql`);
1922
2006
  }
1923
2007
  }
1924
2008
  if (store !== undefined) {
1925
2009
  // ⑧ session mysql 要一个 durable 会话店,memory 后端一只都不建(`plugins/session-store.ts:17`「a DB session backend requires a store backend」)。
1926
2010
  if (store.session === "mysql" && store.backend === "memory") {
1927
- issue(["runtime", "store", "session"], `store.session "mysql" asks for a durable session store, but store.backend "memory" builds none (the engine refuses to start: a DB session backend requires a store backend); ` +
1928
- `set store.session to auto | memory, or store.backend to mysql | pg | local`);
2011
+ issue(["runtime", "store", "session"], buildQuotedIssue `store.session "mysql" asks for a durable session store, but store.backend "memory" builds none (the engine refuses to start: a DB session backend requires a store backend); set store.session to auto | memory, or store.backend to mysql | pg | local`);
1929
2012
  }
1930
2013
  // ⑨ 记忆引擎开 ∧ 方言 pg / tidb ⇒ 引擎绑 SQL 池(`boot/stores.ts:209` / `:232` / `:300`);session memory 时池不开 ⇒ 起服拒。
1931
2014
  if (store.memory.engine === "on" && MEMORY_BACKEND_NEEDS_STORE[store.memory.backend] !== undefined && sql && !sqlPoolOpens) {
1932
- issue(["runtime", "store", "session"], `store.memory.backend "${store.memory.backend}" binds the SQL pool, but store.session "memory" never opens it (the engine refuses to start half-configured); ` +
1933
- `set store.session to auto | mysql, or store.memory.backend to file, or store.memory.engine to off`);
2015
+ issue(["runtime", "store", "session"], buildQuotedIssue `store.memory.backend "${store.memory.backend}" binds the SQL pool, but store.session "memory" never opens it (the engine refuses to start half-configured); set store.session to auto | mysql, or store.memory.backend to file, or store.memory.engine to off`);
1934
2016
  }
1935
2017
  }
1936
2018
  if (store !== undefined) {
1937
2019
  if (sql && store.db === undefined)
1938
- issue(["runtime", "store", "db"], `store.backend "${store.backend}" needs runtime.store.db {host, port, user, database}`);
2020
+ issue(["runtime", "store", "db"], buildQuotedIssue `store.backend "${store.backend}" needs runtime.store.db {host, port, user, database}`);
1939
2021
  if (!sql && store.db !== undefined)
1940
- issue(["runtime", "store", "db"], `store.backend "${store.backend}" uses no SQL server — remove runtime.store.db, or set store.backend to mysql | pg`);
2022
+ issue(["runtime", "store", "db"], buildQuotedIssue `store.backend "${store.backend}" uses no SQL server — remove runtime.store.db, or set store.backend to mysql | pg`);
1941
2023
  const needs = MEMORY_BACKEND_NEEDS_STORE[store.memory.backend];
1942
2024
  if (needs !== undefined && store.backend !== needs) {
1943
- issue(["runtime", "store", "memory", "backend"], `store.memory.backend "${store.memory.backend}" keeps memory in the ${needs} store — it needs store.backend "${needs}" (got "${store.backend}")`);
2025
+ issue(["runtime", "store", "memory", "backend"], buildQuotedIssue `store.memory.backend "${store.memory.backend}" keeps memory in the ${fixed(needs)} store — it needs store.backend "${fixed(needs)}" (got "${store.backend}")`);
1944
2026
  }
1945
2027
  if (requirePrincipal && !sql) {
1946
- issue(["runtime", "store", "backend"], `runtime.requirePrincipal=true (multi-tenant posture) needs a SQL store — store.backend "${store.backend}" has no tenant isolation; set store.backend to mysql | pg`);
2028
+ issue(["runtime", "store", "backend"], buildQuotedIssue `runtime.requirePrincipal=true (multi-tenant posture) needs a SQL store — store.backend "${store.backend}" has no tenant isolation; set store.backend to mysql | pg`);
1947
2029
  }
1948
2030
  }
1949
2031
  if (sql && w.secretRefs.dbPasswordEnv === undefined)
1950
- issue(["secretRefs", "dbPasswordEnv"], `a SQL store (runtime.store.backend mysql | pg) needs secretRefs.dbPasswordEnv — the host env-NAME holding the database password`);
2032
+ issue(["secretRefs", "dbPasswordEnv"], buildQuotedIssue `a SQL store (runtime.store.backend mysql | pg) needs secretRefs.dbPasswordEnv — the host env-NAME holding the database password`);
1951
2033
  if (!sql && w.secretRefs.dbPasswordEnv !== undefined)
1952
- issue(["secretRefs", "dbPasswordEnv"], `secretRefs.dbPasswordEnv is set but there is no SQL store (runtime.store.backend mysql | pg) — remove it`);
2034
+ issue(["secretRefs", "dbPasswordEnv"], buildQuotedIssue `secretRefs.dbPasswordEnv is set but there is no SQL store (runtime.store.backend mysql | pg) — remove it`);
1953
2035
  }
1954
2036
  /** 一台 worker 的声明。写闸 = `WorkerSpec.safeParse`(7.0.0 删除项读到即拒 + 两个结构块 + 跨字段判据)。 */
1955
- export const WorkerSpec = z.preprocess(retiredWorkerFieldIssues, WorkerSpecObject.superRefine(workerSpecIssues));
2037
+ export const WorkerSpec = WorkerSpecObject.superRefine(workerSpecIssues);
1956
2038
  /** Effective route key for a worker = explicit routePrefix, else its name. */
1957
2039
  export function workerRouteKey(w) {
1958
2040
  return w.routePrefix ?? w.name;
@@ -1967,27 +2049,27 @@ export const WORKER_RESOURCE_PRESETS = {
1967
2049
  L: { requests: { cpu: "1", memory: "2Gi" }, limits: { cpu: "4", memory: "4Gi" } },
1968
2050
  };
1969
2051
  export const WorkersConfig = z
1970
- .object({ workers: z.array(WorkerSpec).default([]) })
2052
+ .object({ workers: z.array(WorkerSpec).prefault([]) })
1971
2053
  .superRefine((cfg, ctx) => {
1972
2054
  // worker `name` and effective routePrefix must each be unique across the fleet (design/25 决策7:
1973
- // routePrefix 唯一性 sema-registry 提交时校验). Dup name → container-name clash; dup route → ambiguous
2055
+ // routePrefix 唯一性 web-admin 提交时校验). Dup name → container-name clash; dup route → ambiguous
1974
2056
  // reverse-proxy routing.
1975
2057
  const names = new Set();
1976
2058
  const routes = new Set();
1977
2059
  cfg.workers.forEach((w, i) => {
1978
2060
  if (names.has(w.name))
1979
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate worker name "${w.name}"`, path: ["workers", i, "name"] });
2061
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate worker name "${w.name}"`, path: ["workers", i, "name"] });
1980
2062
  names.add(w.name);
1981
2063
  const route = workerRouteKey(w);
1982
2064
  // point the error at the field that actually set the route (explicit routePrefix, else the name fallback).
1983
2065
  const routeField = w.routePrefix !== undefined ? "routePrefix" : "name";
1984
2066
  if (routes.has(route))
1985
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate routePrefix "${route}" (collides with another worker's routePrefix/name)`, path: ["workers", i, routeField] });
2067
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate routePrefix "${route}" (collides with another worker's routePrefix/name)`, path: ["workers", i, routeField] });
1986
2068
  routes.add(route);
1987
2069
  // replicas + autoscaling = two owners of the same knob (the orchestrator's SSA vs the HPA controller
1988
2070
  // would endlessly fight over spec.replicas) → reject at save, not at reconcile.
1989
2071
  if (w.replicas !== undefined && w.autoscaling) {
1990
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: "replicas and autoscaling are mutually exclusive — the HPA owns the replica count when autoscaling is set", path: ["workers", i, "autoscaling"] });
2072
+ ctx.addIssue({ code: "custom", message: "replicas and autoscaling are mutually exclusive — the HPA owns the replica count when autoscaling is set", path: ["workers", i, "autoscaling"] });
1991
2073
  }
1992
2074
  });
1993
2075
  });
@@ -2004,9 +2086,9 @@ export const WorkerStatus = z.object({
2004
2086
  name: z.string().min(1),
2005
2087
  host: z.string().min(1), // 哪个 reconciler 上报的
2006
2088
  phase: z.enum(["Pending", "Running", "Failed", "Unknown"]),
2007
- conditions: z.array(WorkerCondition).default([]),
2089
+ conditions: z.array(WorkerCondition).prefault([]),
2008
2090
  /** reconciler 处理时的 effective `version` — admin 改 spec 后 version 变,据此判断 status 是否陈旧. */
2009
- observedVersion: z.number().int().nonnegative(),
2091
+ observedVersion: INTEGER.nonnegative(),
2010
2092
  /** reconciler 算的逻辑 spec hash (漂移检测 + 防振荡, design/22 失败模式#1). */
2011
2093
  observedSpecHash: z.string().optional(),
2012
2094
  message: z.string().optional(),
@@ -2018,20 +2100,22 @@ export const WorkerStatus = z.object({
2018
2100
  // - `capacity` DELETED —— 可放性改由探测值折算(HostStatus.probes − Σ已排 worker requests;计数制→资源制
2019
2101
  // 是探测数据齐备后的**独立开关**,E2 placement 承接,切换前沿用计数制兜底、不按票数)。
2020
2102
  // - `approved` NEW —— 默认 false = 待批(不参与自动排布;enforcement 随 E2 placement 生效,E1 只落契约)。
2021
- // 存量迁移 = grandfathering 批量回填 approved:true(migrate.ts),存量机器不出现排布空窗(§11-9)。
2022
- /** DNS-label bound on a host NAME (0.8.0 tightening; single source — schema + the 0.8.1 read-path
2023
- * grandfather clamp in config-fns both use it, so the two can never drift). */
2103
+ // 存量迁移 = grandfathering 批量回填 approved:true(0.5 → 0.6 迁移工具,11.0.0 随 `./migrate` 出口删除),存量机器不出现排布空窗(§11-9)。
2104
+ /** DNS-label bound on a host NAME (0.8.0 tightening; single source — the schema and the one-time migration SS-M2 that
2105
+ * rewrites 0.7-era stored data over it both read this constant). */
2024
2106
  export const HOST_NAME_MAX = 63;
2025
2107
  /** Ceiling on the hosts ARRAY (0.8.0 tightening; same single-source rule as {@link HOST_NAME_MAX}). */
2026
2108
  export const HOSTS_ARRAY_MAX = 1024;
2109
+ /** 两条上界的拒句尾:0.8.0 之前存下的超界数据由一次性迁移 SS-M2 改写(11.0.0 起读侧不再夹紧)。 */
2110
+ const HOSTS_BOUND_SIGNPOST = migrationSignpost("SS-M2", "0.8.0");
2027
2111
  export const HostEntry = z.object({
2028
- // .max(HOST_NAME_MAX=63) = DNS-label bound (E2 F3③ 挂账收账, 0.8.0): center-side probe clamp stays as双保险.
2029
- name: z.string().min(1).max(HOST_NAME_MAX).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces"),
2112
+ // .max(HOST_NAME_MAX=63) = DNS-label bound (E2 F3③ 挂账收账, 0.8.0): web-admin-side probe clamp stays as双保险.
2113
+ name: z.string().min(1).max(HOST_NAME_MAX, `host name longer than ${HOST_NAME_MAX} characters (a DNS label) — ${HOSTS_BOUND_SIGNPOST}`).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces"),
2030
2114
  /** 待批准入编状态机(§2-3):自注册/新登记默认 false = 待批,不参与自动排布;admin 批准(走 publish)→
2031
2115
  * true 参排(enforcement 随 E2)。既有 worker 不受影响(reconciler 仍按具体 host 认领)。 */
2032
2116
  approved: z.boolean().default(false),
2033
2117
  /** 调度标签(如 "gpu"/"region-us"),worker 的 `placement.requires` 据此匹配。 */
2034
- labels: z.array(z.string().min(1)).default([]),
2118
+ labels: z.array(z.string().min(1)).prefault([]),
2035
2119
  /** 关闭 = 完全不可调度(既有 worker 仍由 reconciler 管,但 placement 视它不可用)。 */
2036
2120
  enabled: z.boolean().default(true),
2037
2121
  /** drain = 不再往这台**新**放 worker(既有的留着),用于下线前腾空。 */
@@ -2039,16 +2123,15 @@ export const HostEntry = z.object({
2039
2123
  notes: z.string().optional(),
2040
2124
  });
2041
2125
  export const HostsConfig = z
2042
- // .max(HOSTS_ARRAY_MAX=1024) hosts (E2 F3③ 挂账收账, 0.8.0) — a fleet registry, not a datacenter inventory;
2043
- // an absurdly long array is a poisoned publish, fail-loud at save time. READ path: 0.7-era stored data that
2044
- // exceeds these 0.8 bounds is GRANDFATHERED (clamped + warned, never thrown) inside buildEffective —
2045
- // see grandfatherHostsRead in config-fns (0.8.1, S3 M-1: 收紧不毁存量,E2 血训).
2046
- .object({ hosts: z.array(HostEntry).max(HOSTS_ARRAY_MAX).default([]) })
2126
+ // .max(HOSTS_ARRAY_MAX=1024) hosts (E2 F3③ 挂账收账, 0.8.0) — a fleet host registry, not a datacenter inventory;
2127
+ // an absurdly long array is a poisoned publish, fail-loud at save time. The read path judges with the same schema
2128
+ // (11.0.0: the 0.8.1 read-side clamp is gone; 0.7-era stored data over these bounds is rewritten once by SS-M2).
2129
+ .object({ hosts: z.array(HostEntry).max(HOSTS_ARRAY_MAX, `more than ${HOSTS_ARRAY_MAX} hosts — ${HOSTS_BOUND_SIGNPOST}`).prefault([]) })
2047
2130
  .superRefine((cfg, ctx) => {
2048
2131
  const seen = new Set();
2049
2132
  cfg.hosts.forEach((h, i) => {
2050
2133
  if (seen.has(h.name))
2051
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate host "${h.name}"`, path: ["hosts", i, "name"] });
2134
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate host "${h.name}"`, path: ["hosts", i, "name"] });
2052
2135
  seen.add(h.name);
2053
2136
  });
2054
2137
  });
@@ -2058,28 +2141,28 @@ export const HostsConfig = z
2058
2141
  // (仅身份字段 {name, approved:false},幂等一条,来源 token 记审计——EXPERT-REDESIGN §2-1/§11-4)。E2
2059
2142
  // placement 的资源制折算消费它(可分配 = probes − Σ已排 worker requests);从未上报的机器不参与自动排布。
2060
2143
  // Sanity ceilings (E2 F3③ 挂账收账, 0.8.0): a forged/absurd probe (cpuCores=1e12) would suck every auto
2061
- // worker onto one host. Ceilings MIRROR center's probeCapacities clamp (PROBE_CPU_CORES_CEILING=4096 /
2062
- // PROBE_MEM_BYTES_CEILING=2^50=1PiB) — schema now fail-louds at report time; center's clamp stays 双保险.
2144
+ // worker onto one host. Ceilings MIRROR web-admin's probeCapacities clamp (PROBE_CPU_CORES_CEILING=4096 /
2145
+ // PROBE_MEM_BYTES_CEILING=2^50=1PiB) — schema now fail-louds at report time; web-admin's clamp stays 双保险.
2063
2146
  export const PROBE_CPU_CORES_MAX = 4096;
2064
2147
  export const PROBE_MEM_BYTES_MAX = 2 ** 50; // 1 PiB
2065
2148
  export const HostProbes = z.object({
2066
2149
  /** 探测到的 CPU 核数(docker info NCPU / k8s node allocatable cpu)。可为小数(k8s allocatable 如 3.9)。 */
2067
2150
  cpuCores: z.number().finite().positive().max(PROBE_CPU_CORES_MAX),
2068
2151
  /** 探测到的内存字节数(docker info MemTotal / k8s node allocatable memory)。 */
2069
- memBytes: z.number().int().positive().max(PROBE_MEM_BYTES_MAX),
2152
+ memBytes: INTEGER.positive().max(PROBE_MEM_BYTES_MAX),
2070
2153
  /** 当前跑着的容器数(docker info ContainersRunning / k8s node 上本 fleet 的 pod 数)。 */
2071
- containersRunning: z.number().int().nonnegative(),
2154
+ containersRunning: INTEGER.nonnegative(),
2072
2155
  /** k8s backend 补充:node 名 + allocatable(与顶层同单位——cpuCores 核数 / memBytes 字节)。docker 无此段。 */
2073
2156
  k8sNode: z
2074
2157
  .object({
2075
2158
  name: z.string().min(1),
2076
- allocatable: z.object({ cpuCores: z.number().finite().positive().max(PROBE_CPU_CORES_MAX).optional(), memBytes: z.number().int().positive().max(PROBE_MEM_BYTES_MAX).optional() }).optional(),
2159
+ allocatable: z.object({ cpuCores: z.number().finite().positive().max(PROBE_CPU_CORES_MAX).optional(), memBytes: INTEGER.positive().max(PROBE_MEM_BYTES_MAX).optional() }).optional(),
2077
2160
  })
2078
2161
  .optional(),
2079
2162
  });
2080
2163
  export const HostStatus = z.object({
2081
2164
  /** host name(hosts 域 HostEntry.name / ORCH_HOST_ID 同字符集,同 .max(HOST_NAME_MAX=63) DNS-label bound)。
2082
- * status 上报是 WRITE 路径 → fail-loud 保持(有意设计,S3 M-1 订正注记:center hosts/status PUT 无独立
2165
+ * status 上报是 WRITE 路径 → fail-loud 保持(有意设计,S3 M-1 订正注记:web-admin hosts/status PUT 无独立
2083
2166
  * probe 上限,schema 即闸;读路径 grandfather 只涉 config-hosts 域,不涉此处)。 */
2084
2167
  host: z.string().min(1).max(HOST_NAME_MAX).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces"),
2085
2168
  backend: z.enum(["docker", "k8s"]),
@@ -2090,7 +2173,7 @@ export const HostStatus = z.object({
2090
2173
  // ── Domain: skills (中心化 skill 分发 — the mcp/skills design note, Roadmap B1) ─────────
2091
2174
  // A skill is a prompt-level capability: frontmatter (name/description/scenarios) + markdown `content`
2092
2175
  // the service feeds into the agent's model context when the skill is invoked (core progressive disclosure
2093
- // — NOT the base system prompt). Still centrally-delivered agent instructions. The center stores the FULL skill
2176
+ // — NOT the base system prompt). Still centrally-delivered agent instructions. Web-admin stores the FULL skill
2094
2177
  // (incl. content, admin-edited); /effective ships a MANIFEST (content → contentHash) and the service
2095
2178
  // lazily pulls the body by hash (slice 2). Targeting is by `scenarios[]`, filtered service-side exactly
2096
2179
  // like the in-image `skillsForScenario` (the mcp/skills design note §3.1, §6 Q2). Applied at service boot/reconcile,
@@ -2098,27 +2181,27 @@ export const HostStatus = z.object({
2098
2181
  // (injected into the model on invoke) → writing this domain is gated to `publisher` + the publish gate
2099
2182
  // (§3.3, DOMAIN_WRITE_ROLE in store.ts).
2100
2183
  export const SkillSpec = z.object({
2101
- /** Catalog key — must match the in-image SKILLS_DIR name to OVERRIDE it (§6 Q4 中心按名覆盖). */
2184
+ /** Catalog key — must match the in-image SKILLS_DIR name to OVERRIDE it (§6 Q4 web-admin 按名覆盖). */
2102
2185
  name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
2103
2186
  /** One-line summary (frontmatter `description`) — surfaced to the model for skill selection. */
2104
2187
  description: z.string().default(""),
2105
2188
  /** Scenarios this skill applies to; empty = all scenarios (service-side filter, same as skillsForScenario). */
2106
- scenarios: z.array(z.string().min(1)).default([]),
2189
+ scenarios: z.array(z.string().min(1)).prefault([]),
2107
2190
  /** Per-roster targeting (v2, the mcp/skills design note §6 Q2): roster NAMEs this skill is for. Empty = all rosters.
2108
2191
  * Filtered at GET /effective?worker=<name> by the worker's roster; the unscoped global pull ships all. */
2109
- rosters: z.array(z.string().min(1)).default([]),
2192
+ rosters: z.array(z.string().min(1)).prefault([]),
2110
2193
  /** The markdown body injected into the model context on invoke (progressive disclosure). Stored in full here; shipped as a hash in /effective. */
2111
2194
  content: z.string().default(""),
2112
2195
  enabled: z.boolean().default(true),
2113
2196
  notes: z.string().optional(),
2114
2197
  });
2115
2198
  export const SkillsConfig = z
2116
- .object({ skills: z.array(SkillSpec).default([]) })
2199
+ .object({ skills: z.array(SkillSpec).prefault([]) })
2117
2200
  .superRefine((cfg, ctx) => {
2118
2201
  const seen = new Set();
2119
2202
  cfg.skills.forEach((s, i) => {
2120
2203
  if (seen.has(s.name))
2121
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate skill "${s.name}"`, path: ["skills", i, "name"] });
2204
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate skill "${s.name}"`, path: ["skills", i, "name"] });
2122
2205
  seen.add(s.name);
2123
2206
  });
2124
2207
  });
@@ -2126,15 +2209,15 @@ export const SkillsConfig = z
2126
2209
  * (`readEffectiveWire`). Derived from {@link SkillSpec} exactly like {@link SkillManifestEntry} is (omit
2127
2210
  * `content` / require `contentHash`) so schema, entry type and interface can never drift apart. Only
2128
2211
  * `min(1)` on contentHash: the hash is the service's PULL KEY for the content endpoint, its exact format
2129
- * is the center's writing concern — re-pinning it here would make an old reader reject a newer center's
2212
+ * is web-admin's writing concern — re-pinning it here would make an old reader reject a newer web-admin's
2130
2213
  * legal hash scheme. */
2131
2214
  export const SkillsManifestSchema = z.object({
2132
- skills: z.array(SkillSpec.omit({ content: true }).extend({ contentHash: z.string().min(1) })).default([]),
2215
+ skills: z.array(SkillSpec.omit({ content: true }).extend({ contentHash: z.string().min(1) })).prefault([]),
2133
2216
  });
2134
2217
  // ── Domain: mcp (MCP server 注册表 — the mcp/skills design note, Roadmap B1) ────────────
2135
- // sema-registry = an MCP-server REGISTRY: a scenario mounts a subset by `scenarios[]`. Maps onto core's
2218
+ // web-admin = an MCP-server REGISTRY: a scenario mounts a subset by `scenarios[]`. Maps onto core's
2136
2219
  // `McpServerSpec = { name, transport: {kind:"stdio",command,args?,env?} | {kind:"http",url,headers?}, allowTools?, contentOrigin?, source? }`.
2137
- // SECRET BOUNDARY (§3.1, design/15): a transport's env/header VALUES may be secret → the center stores only
2220
+ // SECRET BOUNDARY (§3.1, design/15): a transport's env/header VALUES may be secret → web-admin stores only
2138
2221
  // env-NAME references (`envRefs`/`headerRefs`: target-key → an ENV_NAME in the SERVICE's env). The service's
2139
2222
  // `resolveSpec` reads the real values from its own environment — this DB never holds a credential. Like
2140
2223
  // skills, MCP is applied at boot/reconcile (not hot) and writing is gated to `publisher` + the publish gate.
@@ -2184,12 +2267,10 @@ export const McpTransport = z.discriminatedUnion("kind", [
2184
2267
  }),
2185
2268
  ]);
2186
2269
  /** The closed word table for {@link McpServerSpec}.contentOrigin — a MIRROR literal of the engine's
2187
- * `ToolContentOrigin`. This package deliberately carries no engine dependency axis (the same ruling that made
2188
- * PROMPT_ARTIFACT_LIMITS a mirror constant), so the cross-package enforcement point is NOT here: the consumer
2189
- * compile-pins its own copy against the engine type in both directions (AssertNever pair), and on picking this
2190
- * package up it should add a same-source assertion against THIS table too — it is the one repo holding both
2191
- * dependencies. Inside this repo the table is pinned as a literal only (a drift surfaces at the consumer's
2192
- * pickup gate, never silently on the wire — an out-of-table word is rejected at parse on both sides). */
2270
+ * `ToolContentOrigin`. This package carries no engine RUNTIME dependency (the same ruling that made
2271
+ * PROMPT_ARTIFACT_LIMITS a mirror constant); the mirror is pinned HERE, by its owner: the upstream drift gate
2272
+ * (group ⑥) asserts at compile time that this union and the engine type are the same in both directions, against the
2273
+ * engine the devDependency pins. Consumers import this table — they keep no copy of their own. */
2193
2274
  export const MCP_CONTENT_ORIGINS = ["local", "execution", "external"];
2194
2275
  export const McpServerSpec = z.object({
2195
2276
  name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
@@ -2197,10 +2278,10 @@ export const McpServerSpec = z.object({
2197
2278
  /** Allowlist of tool names exposed from this server (core `allowTools`). Empty/undefined = all. */
2198
2279
  allowTools: z.array(z.string().min(1)).optional(),
2199
2280
  /** Scenarios this server is mounted in; empty = all (service-side filter, same as skills). */
2200
- scenarios: z.array(z.string().min(1)).default([]),
2281
+ scenarios: z.array(z.string().min(1)).prefault([]),
2201
2282
  /** Per-roster targeting (v2): roster NAMEs this server is for. Empty = all rosters. Filtered at
2202
2283
  * GET /effective?worker=<name> by the worker's roster; the unscoped global pull ships all. */
2203
- rosters: z.array(z.string().min(1)).default([]),
2284
+ rosters: z.array(z.string().min(1)).prefault([]),
2204
2285
  enabled: z.boolean().default(true),
2205
2286
  /** Per-server opt-in for INBOUND MCP elicitation (core `McpServerSpec.elicitation`, design/99 §E23). Default
2206
2287
  * OFF = fail-closed: only an opted-in server may prompt the user mid-tool-call. Service forwards this into the
@@ -2245,19 +2326,19 @@ export const McpServerSpec = z.object({
2245
2326
  notes: z.string().optional(),
2246
2327
  });
2247
2328
  export const McpConfig = z
2248
- .object({ servers: z.array(McpServerSpec).default([]) })
2329
+ .object({ servers: z.array(McpServerSpec).prefault([]) })
2249
2330
  .superRefine((cfg, ctx) => {
2250
2331
  const seen = new Set();
2251
2332
  cfg.servers.forEach((s, i) => {
2252
2333
  if (seen.has(s.name))
2253
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate mcp server "${s.name}"`, path: ["servers", i, "name"] });
2334
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate mcp server "${s.name}"`, path: ["servers", i, "name"] });
2254
2335
  seen.add(s.name);
2255
2336
  });
2256
2337
  });
2257
2338
  // ── Domain: plugins (plugin SOURCE REFERENCES — C2 plugins 上云, additive 0.5.0) ──────────────────────────
2258
2339
  // The cloud stores WHERE a plugin comes from, NEVER its content (产品拍板): {name, source, version/sha pin}.
2259
2340
  // The consuming shell materializes the plugin locally from the ref (git clone / marketplace install) — the
2260
- // registry never hosts executable plugin bytes, so there is nothing here to content-scan or lazily serve.
2341
+ // web-admin never hosts executable plugin bytes, so there is nothing here to content-scan or lazily serve.
2261
2342
  // Plugins are an EXECUTABLE surface (commands/hooks/MCP ride in) → write-gated to `publisher` like skills/
2262
2343
  // mcp (DOMAIN_WRITE_ROLE) and capped per-principal by entitlement `visiblePlugins` (deny-wins, same
2263
2344
  // mechanics as visibleMcp/visibleSkills — B1: a personal plugin ref is a net-new capability).
@@ -2274,18 +2355,18 @@ export const PLUGIN_ORIGIN = z
2274
2355
  .min(1)
2275
2356
  .refine((s) => !containsSecretToken(s), "plugin source must not contain a secret literal — private remotes authenticate via the consuming machine's git credentials, never an inline token")
2276
2357
  .refine((s) => !hasNul(s), "plugin source must not contain a NUL byte")
2277
- .refine((s) => /^https:\/\//i.test(s), "plugin source must be an https:// URL (contract: https-only; ssh/scp/git/file/plain-http and owner/repo shorthand are not accepted — domestic mirrors ride an https mirror domain via PLUGINS_ALLOW_HOSTS)")
2278
- .refine((s) => {
2279
- if (!/^https:\/\//i.test(s))
2280
- return true; // 已被上一条拒;避免对非 URL 再报一条噪声
2281
- try {
2282
- const u = new URL(s);
2283
- return !carriesUserinfo(u) && !urlQueryFragmentHasSecret(u);
2358
+ .superRefine((s, ctx) => {
2359
+ // The shape is THE URL judge's ({@link httpUrlFlaws}); this seat adds two rules of its own: https only, and spelled in full
2360
+ // (`https://…` as written — git reads the raw string, and WHATWG's repair of `https:host/x` into `https://host/x` is not git's).
2361
+ const flaws = httpUrlFlaws(s, ["userinfo"]);
2362
+ if (flaws.includes("not-http-url") || new URL(s).protocol !== "https:" || s.slice(0, 8).toLowerCase() !== "https://") {
2363
+ ctx.addIssue({ code: "custom", message: "plugin source must be an https:// URL (contract: https-only; ssh/scp/git/file/plain-http and owner/repo shorthand are not accepted — domestic mirrors ride an https mirror domain via PLUGINS_ALLOW_HOSTS)" });
2364
+ return;
2284
2365
  }
2285
- catch {
2286
- return false; // claims to be https but does not parse → reject
2366
+ if (flaws.includes("userinfo") || urlQueryFragmentHasSecret(new URL(s))) {
2367
+ ctx.addIssue({ code: "custom", message: "an https plugin source must not embed credentials (userinfo) or a secret-bearing query/fragment" });
2287
2368
  }
2288
- }, "an https plugin source must not embed credentials (userinfo) or a secret-bearing query/fragment");
2369
+ });
2289
2370
  /** A full 40-hex git commit SHA — the ONLY pin form the supply-side treats as 钉死 (a branch/tag `version`
2290
2371
  * is a moving ref; only a commit pin makes the materialized bytes reproducible). */
2291
2372
  export const GIT_COMMIT_SHA = z.string().regex(/^[a-f0-9]{40}$/, "must be a full 40-character lowercase git commit SHA");
@@ -2316,37 +2397,40 @@ export const PluginSpec = z.object({
2316
2397
  notes: z.string().optional(),
2317
2398
  });
2318
2399
  export const PluginsConfig = z
2319
- .object({ plugins: z.array(PluginSpec).default([]) })
2400
+ .object({ plugins: z.array(PluginSpec).prefault([]) })
2320
2401
  .superRefine((cfg, ctx) => {
2321
2402
  const seen = new Set();
2322
2403
  cfg.plugins.forEach((p, i) => {
2323
2404
  if (seen.has(p.name))
2324
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate plugin "${p.name}"`, path: ["plugins", i, "name"] });
2405
+ ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate plugin "${p.name}"`, path: ["plugins", i, "name"] });
2325
2406
  seen.add(p.name);
2326
2407
  });
2327
2408
  });
2328
2409
  // ── The domain registry ───────────────────────────────────────────────────────
2329
- // ═══ prompts 域(center prompts 战役 v1;[ref] 五定夺/[1051] 产品终稿/[1057] M2 下发形)═══
2410
+ // ═══ prompts 域(web-admin prompts 战役 v1;[ref] 五定夺/[1051] 产品终稿/[1057] M2 下发形)═══
2330
2411
  // schema=编译目标非用户界面([1051]⓪):三卡(角色卡/场景说明/行为守则)是 web UI 编译层心智,
2331
- // 本契约只承载编译产物(typed 声明)与 binding。单真源纪律:存储/解析在 center(sema-web),
2412
+ // 本契约只承载编译产物(typed 声明)与 binding。单真源纪律:存储/解析在 web-admin(sema-web),
2332
2413
  // 消费投影在 server(声明数组逐字喂 core typed stableBlocks 钩子),schema 恒在本仓(零第二份)。
2414
+ /** 提示声明的槽位闭集(core `PromptTextDeclaration.slot` 同形):identity = 角色卡、scenario = 场景说明、behavior = 行为守则。
2415
+ * declaration 轴与 catalog 轴({@link PromptArtifactSection})共用这一只。 */
2416
+ export const PromptSlot = z.enum(["identity", "scenario", "behavior"]);
2333
2417
  /** core `PromptTextDeclaration` 的契约层同形(core prompts/default.d.ts 逐字:id/slot 闭集/text)。
2334
2418
  * slot ↔ 三卡:identity=角色卡、scenario=场景说明、behavior=行为守则([1051]①)。 */
2335
2419
  export const PromptTextDeclaration = z.object({
2336
2420
  id: z.string().min(1).max(128),
2337
- slot: z.enum(["identity", "scenario", "behavior"]),
2421
+ slot: PromptSlot,
2338
2422
  text: z.string().max(65536),
2339
2423
  /** core 1.315 opt-in 逐 section 对账锚([1062]①):声明方对 `text` 铸 sha256,core 校验后逐字
2340
2424
  * 透传进 manifest `contentHash`(错值丢弃+warn,core 永不自算非盐化 digest)。缺省=退化为
2341
2425
  * 整包 contentDigest 对账。契约层显式声明防非 strict parse strip 断锚([1062]③)。 */
2342
- contentHash: z.string().regex(/^sha256:[0-9a-f]{64}$/).optional(),
2426
+ contentHash: Sha256Digest.optional(),
2343
2427
  });
2344
2428
  /** 三维 binding([ref]①a:v1=global/worker/scenario,砍 model 维——per-model 微调走
2345
- * ModelEntry.promptGuidance 专用通道)。covering 语义([1050]①b,解析只在 center 一处):
2429
+ * ModelEntry.promptGuidance 专用通道)。covering 语义([1050]①b,解析只在 web-admin 一处):
2346
2430
  * 同 slot **层间整体替换**(胜出层该 slot 声明数组整体替换低层同 slot 全部条目;层内数组保序,
2347
2431
  * 与 core assemble「declaration order preserved」无缝);层序 scenario > worker > global。
2348
2432
  * 发布即物化([1051]②a):declarations 是 publish 时物化的不可变声明(意图层/编译器版本归
2349
- * center 编辑面存储,非跨仓契约面)。 */
2433
+ * web-admin 编辑面存储,非跨仓契约面)。 */
2350
2434
  export const PromptBinding = z.object({
2351
2435
  /** 绑定层。 */
2352
2436
  level: z.enum(["global", "worker", "scenario"]),
@@ -2372,8 +2456,8 @@ export const PromptBinding = z.object({
2372
2456
  // (overlays/profiles/modelPolicies/bindings)是后续 additive 切片,本轴到时 additive 扩,零迁移。
2373
2457
  // 单真源纪律:server 候选门用 core 导出的 `verifyPromptArtifact`(digest 复算+上限+range 全管线),
2374
2458
  // 本 zod 只是**传输门**(防 DoS/坏形早拒);上限=[[镜像常量]],见 PROMPT_ARTIFACT_LIMITS。
2375
- /** core `ARTIFACT_LIMITS` 的镜像常量([1247]②/[1254] 拍:web/registry-core 不加 core 依赖轴 ⇒ 镜像
2376
- * + **server 仓 CI 对表钉「逐键 === core 导出」**——改上限必须同 PR 动两处+过钉,三门数值恒等自证)。 */
2459
+ /** core `ARTIFACT_LIMITS` 的镜像常量(本包不加 core 运行期依赖轴 ⇒ 镜像)。对表钉住在**镜像的属主**这里:上游漂移门 ⑤ 逐键
2460
+ * === devDependency 钉住的那只 core 的导出 —— 改上限必须同版动两处、过钉。 */
2377
2461
  export const PROMPT_ARTIFACT_LIMITS = {
2378
2462
  maxSections: 64,
2379
2463
  maxSectionBytes: 32_000,
@@ -2386,9 +2470,9 @@ export const PROMPT_ARTIFACT_LIMITS = {
2386
2470
  * 为 UTF-16 码元数,core verify 按 UTF-8 字节复核——传输门宽、语义门准)。 */
2387
2471
  export const PromptArtifactSection = z.object({
2388
2472
  id: z.string().min(1).max(PROMPT_ARTIFACT_LIMITS.maxIdLength).regex(/^[a-z0-9][a-z0-9/._-]*$/),
2389
- slot: z.enum(["identity", "scenario", "behavior"]),
2473
+ slot: PromptSlot,
2390
2474
  text: z.string().min(1).max(PROMPT_ARTIFACT_LIMITS.maxSectionBytes),
2391
- contentHash: z.string().regex(/^sha256:[0-9a-f]{64}$/),
2475
+ contentHash: Sha256Digest,
2392
2476
  });
2393
2477
  /** core `PromptCatalogSnapshot` 同形。packId=逻辑名(如 `center:acme@3`),历史身份恒在 digest。 */
2394
2478
  export const PromptCatalogSnapshot = z.object({
@@ -2401,7 +2485,7 @@ export const PromptCatalogSnapshot = z.object({
2401
2485
  export const PromptEpochArtifactPayload = z.object({
2402
2486
  schemaVersion: z.literal(1),
2403
2487
  assemblyApi: z.literal(1),
2404
- catalogDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
2488
+ catalogDigest: Sha256Digest,
2405
2489
  compatibleCore: z
2406
2490
  .object({
2407
2491
  minCore: z.string().regex(/^\d+\.\d+\.\d+$/).optional(),
@@ -2418,16 +2502,16 @@ export const PromptEpochArtifactPayload = z.object({
2418
2502
  export const PublishedPromptArtifactEnvelope = z.object({
2419
2503
  artifact: z.object({
2420
2504
  payload: PromptEpochArtifactPayload,
2421
- artifactDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
2505
+ artifactDigest: Sha256Digest,
2422
2506
  }),
2423
2507
  sourceRevision: z.string().min(1).max(256),
2424
2508
  });
2425
- /** prompts 域存储形 v1(center 存储真形;[1043] PromptCatalogPayload 的 v1 最小面)。
2509
+ /** prompts 域存储形 v1(web-admin 存储真形;[1043] PromptCatalogPayload 的 v1 最小面)。
2426
2510
  * overlays/profiles/modelPolicies = 协议 §5.1 的后续 additive 面,留位不预设形(等 core S6
2427
2511
  * catalog schema 班车,additive 补键零迁移)。 */
2428
2512
  export const PromptsConfig = z.object({
2429
- bindings: z.array(PromptBinding).max(256).default([]),
2430
- /** catalog 轴存储位(0.10.20,[985]a):center 发布门铸好的 immutable envelope 原样存(发布即
2513
+ bindings: z.array(PromptBinding).max(256).prefault([]),
2514
+ /** catalog 轴存储位(0.10.20,[985]a):web-admin 发布门铸好的 immutable envelope 原样存(发布即
2431
2515
  * 物化,店内零解析);local FileConfigStore lane 的 config.d/prompts.json 也可直接携带——catalog
2432
2516
  * 免 covering 解析,两 lane 自此同形(结束 local lane prompts 不透传的过渡态,[1234]① 位)。 */
2433
2517
  catalog: PublishedPromptArtifactEnvelope.optional(),
@@ -2438,17 +2522,22 @@ export const PromptsConfig = z.object({
2438
2522
  * `promptsContentDigest`);sections=covering 已走完的基线终形(worker/core 零解析义务);
2439
2523
  * scenarioOverrides[s]=该场景完整重算终形(整包替换非增量),无键=用基线;fail-closed=
2440
2524
  * publish 闸关时整个 key 不出现。 */
2525
+ /** prompts 包身份 `packId` 的前缀(铸点 `promptsContentDigest` 与判官 {@link PromptPackId} 都引它)。值落在引擎的会话记录里、是
2526
+ * epoch 不误重 pin 的依据,所以值本身不随叫法归一改(换前缀 = 每条活会话的提示包身份翻一次,要引擎先读 epoch 判据)。 */
2527
+ export const PROMPT_PACK_ID_PREFIX = "center:";
2528
+ /** prompts 包身份的形:前缀 + 内容摘要的前 12 位小写 hex(纯内容寻址:同内容重发布 = 同 packId)。判它的每一处都引这一只。 */
2529
+ export const PromptPackId = z.string().regex(new RegExp(`^${PROMPT_PACK_ID_PREFIX}[0-9a-f]{12}$`));
2441
2530
  export const EffectivePrompts = z.object({
2442
- packId: z.string().regex(/^center:[0-9a-f]{12}$/),
2443
- contentDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
2531
+ packId: PromptPackId,
2532
+ contentDigest: Sha256Digest,
2444
2533
  sections: z.array(PromptTextDeclaration),
2445
2534
  scenarioOverrides: z.record(z.string(), z.array(PromptTextDeclaration)).optional(),
2446
2535
  });
2447
2536
  /** effective 下发形 v2(双轴,[985]a §1/[1242] Q1 裁:单 domain 单键、两轴是过渡窗内的两种表达)。
2448
2537
  * declarations=现行 A 形(centerPromptProvider 继续消费);catalog=B 形(core S1 verify 管线的输入,
2449
2538
  * server 原样交 core 不解释)。至少一轴必须在场(空对象=坏形);催收面语义照旧=publish 闸关时整个
2450
- * `prompts` key 消失(fail-closed)。存量兼容:server 候选门同时认本形与 legacy 平铺 EffectivePrompts
2451
- * (center 停发平铺后自然退役,server 零改动)。 */
2539
+ * `prompts` key 消失(fail-closed)。引擎的候选门**只认本形**:没有 `schemaVersion` 的平铺 {@link EffectivePrompts} 体被响亮拒
2540
+ * (整个 prompts 面不采用,落最后一份好配置 / 内置)。 */
2452
2541
  export const EffectivePromptsWire = z
2453
2542
  .object({
2454
2543
  schemaVersion: z.literal(1),
@@ -2467,18 +2556,17 @@ export const DOMAIN_SCHEMAS = {
2467
2556
  collab: CollabConfig, // 0.7.0: replaces `teams` (EXPERT-REDESIGN §8/§9 同批换刀 — no editing gap)
2468
2557
  workers: WorkersConfig,
2469
2558
  hosts: HostsConfig,
2470
- runtime: RuntimeConfig,
2471
2559
  governance: GovernanceConfig,
2472
2560
  entitlement: EntitlementConfig,
2473
2561
  execution: ExecutionPolicy, // 0.8.0: S3 execution-lane policy (scope 级; client-advisory until service gate)
2474
2562
  projects: ProjectsConfig, // 0.10.0: 142-S3 project identity ledger + 无仓铸造权威 (scope=tenant 轴; NOT portable — 租户身份数据)
2475
- prompts: PromptsConfig, // 0.10.18: center prompts 战役 v1([ref] 五定夺/产品终稿/M2 下发形;写门/store 携带解锁=[1064]②)
2563
+ prompts: PromptsConfig, // 0.10.18: web-admin prompts 战役 v1([ref] 五定夺/产品终稿/M2 下发形;写门/store 携带解锁=[1064]②)
2476
2564
  limits: LimitsConfig, // 0.19.0: cost/限流族热更新发布位(消费方 #322 批1;本地腿闭集缺口的源头修复)
2477
2565
  // integrations + access: DELETED in 0.6.0 (EXPERT-REDESIGN §9) — parseDomain/effective/路由随之 404。
2478
2566
  };
2479
2567
  // ── Effective-config PROVENANCE (the unified shape TOC `sema config --effective` and TOB `/effective?principal=`
2480
2568
  // step-5 trace both emit, so "why is this value what it is" renders identically across both seats). Settled
2481
- // via the center control-plane design gate③ (see the center control-plane design note §R2-T/§10). A pure
2569
+ // via web-admin control-plane design gate③ (see web-admin control-plane design note §R2-T/§10). A pure
2482
2570
  // CONTRACT type — no runtime dep on the engine; the axis VALUES (enforcement/lockBehavior) mirror @sema-agent/
2483
2571
  // core's safety vocabulary, validated by a dev-only conformance test (never a runtime import).
2484
2572
  /** One provenance source in a key's resolution trail. Two orthogonal axes: the TOC file LAYER, or the TOB
@@ -2497,7 +2585,7 @@ export const SettingSource = z.discriminatedUnion("axis", [
2497
2585
  * `lockBehavior` (BEHAVIOR / what the managed layer does) is orthogonal, only meaningful under managed enforcement. */
2498
2586
  export const EffectiveKey = z.object({
2499
2587
  key: z.string(),
2500
- value: z.unknown(),
2588
+ value: z.unknown().optional(),
2501
2589
  winningSource: SettingSource,
2502
2590
  locked: z.boolean(),
2503
2591
  enforcement: z.enum(["gate", "server", "resolver", "client"]),