@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
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Pure config functions + data shapes — LIFTED verbatim out of sema-registry's `store.ts` (the half that
3
- * depends only on `./types`, zero app/DB/next coupling). sema-registry re-exports these so its existing
2
+ * Pure config functions + data shapes — LIFTED verbatim out of web-admin's `store.ts` (the half that
3
+ * depends only on `./types`, zero app/DB/next coupling). web-admin re-exports these so its existing
4
4
  * `@/lib/config` importers keep working unchanged (ONE copy of the code, here).
5
5
  *
6
6
  * Two DETERMINISM refactors vs. the original store.ts (so the package is wall-clock-free and a local file
@@ -10,15 +10,16 @@
10
10
  * 2. `emptyEffective()` seeds the epoch (`new Date(0).toISOString()`) instead of the wall clock, matching
11
11
  * the stores' existing "epoch sentinel" reduce seed.
12
12
  *
13
- * The fat `ConfigStore` interface itself STAYS in sema-registry (app-only, ~32 publish/RBAC/worker-status
13
+ * The fat `ConfigStore` interface itself STAYS in web-admin (app-only, ~32 publish/RBAC/worker-status
14
14
  * methods); only the narrow {@link ConfigReader} (reader.ts) is carried here.
15
15
  */
16
16
  // zod is a VALUE import here (not just a type): {@link salvageEntries} introspects a domain schema to find
17
17
  // its entry collections. zod is this package's one runtime dependency, so this costs nothing new.
18
18
  import { z } from "zod";
19
19
  import { MODEL_ENTRY_REFUSAL_CODES, ModelRefusalsWire } from "./fleet.js";
20
- import { carriesUserinfo, secretScanForms } from "./secret-scan.js"; // package-private display-side normalization + the userinfo judgement (not in the barrel)
21
- import { DOMAIN_SCHEMAS, GOVERNANCE_MIRROR_KEYS, HOSTS_ARRAY_MAX, HOST_NAME_MAX, InfraCostRates, LimitsConfig, PRIVATE_KEY_PEM_HEADER, SkillsManifestSchema, containsSecretToken, isDomainName } from "./types.js";
20
+ import { carriesUserinfo, secretScanForms } from "./secret-scan.js"; // package-private normalization (both scanners read it) + the userinfo judgement (not in the barrel)
21
+ import { unwrapSchema } from "./zod-unwrap.js"; // package-private: the ONE reading of zod's wrapper layers (salvage + the one-time migrations share it)
22
+ import { DOMAIN_SCHEMAS, InfraCostRates, LimitsConfig, PRIVATE_KEY_PEM_HEADER, RETIRED_DOMAINS, SkillsManifestSchema, containsSecretToken, isDomainName, isRetiredDomainName, renderQuotedSentence, retiredDomainSentence } from "./types.js";
22
23
  /** Redact secret-shaped tokens from a validation MESSAGE — a zod error echoes the offending value (e.g.
23
24
  * `received 'sk-live-…'`), so a secret pasted into the WRONG field would leak via the error string. Redacts
24
25
  * space-spanning auth credentials (`Bearer <token>`) FIRST (the token pass can't see across the space), then
@@ -26,9 +27,8 @@ import { DOMAIN_SCHEMAS, GOVERNANCE_MIRROR_KEYS, HOSTS_ARRAY_MAX, HOST_NAME_MAX,
26
27
  * containsSecretToken). invariant ①: a secret VALUE never rides an error. R13/R14.
27
28
  *
28
29
  * **Output invariant** (S-712 verification pass N1 + codex review [high] ×2; 6.3.0): a string holding a private-key
29
- * PEM header ANYWHERE ({@link PRIVATE_KEY_PEM_HEADER}) in ANY of its {@link secretScanForms} `forms` is withheld WHOLE, a
30
- * string whose normalization did not finish (`unresolved`) is withheld WHOLE, and no result trips `containsSecretToken` on
31
- * any of its forms.
30
+ * PEM header ANYWHERE ({@link PRIVATE_KEY_PEM_HEADER}) in ANY of its {@link secretScanForms} `forms` is withheld WHOLE, and no
31
+ * result trips `containsSecretToken` (which counts an unfinished normalization as secret-shaped, 11.0.0).
32
32
  * The whole-string judgement is ONE rule — both detectors run over the normalized forms: the PEM header on every form of
33
33
  * the ORIGINAL text, `containsSecretToken` on every form of the targeted result; what could not be normalized does not go
34
34
  * out (6.3.0, codex r1 F1: `-----BEGIN+PRIVATE+KEY` form-encoded headers and four-layer encodings both rode out).
@@ -40,45 +40,97 @@ import { DOMAIN_SCHEMAS, GOVERNANCE_MIRROR_KEYS, HOSTS_ARRAY_MAX, HOST_NAME_MAX,
40
40
  * key body after it would ride out untouched — codex verification round). A PEM header carries spaces, so the
41
41
  * targeted passes can never isolate it. Every free-text warning face that goes through here (coordinates, error text)
42
42
  * inherits the same guarantee; the entry identity face additionally has to be an identifier ({@link isDisclosableIdentity}).
43
- * Write and display read the private-key header at the same PLACE and with the same regex since 10.0.0: the validation-side
44
- * detector `containsSecretToken` reads {@link PRIVATE_KEY_PEM_HEADER} anywhere in a string too, on the raw text and its first three
45
- * percent layers. They do NOT yet read the same FORMS: this redactor reads more (deeper layers, the `+` and boundary readings of
46
- * `secretScanForms`), so a header spelled only in one of those forms is withheld on display but still passes write validation —
47
- * an open gap (a pin in the 10.0.0 test file registers it), not a contract. The redactor also keeps its own PEM check on the ORIGINAL
48
- * text's forms because the token pass above can rewrite the header away before `containsSecretToken` ever sees the result. */
43
+ * Write and display are ONE scanner since 11.0.0 (S-1274): `containsSecretToken` reads the same `secretScanForms` forms through the
44
+ * same shape table, so "withheld on display" and "refused on write" no longer disagree on any form. The redactor keeps its own PEM
45
+ * check on the ORIGINAL text's forms only because the token pass above can rewrite the header away before the scanner sees the
46
+ * result. */
47
+ /** What separates the candidate segments of a free text (a URL candidate is a run between these): whitespace only —— quotes,
48
+ * angle brackets and backticks can sit inside a URL password (codex r1), so a quoted URL goes out as one segment, quotes and all.
49
+ * Split (linear), not a scanning regex — a scanning `[^…]*@[^…]*` is quadratic on a long run without `@`. */
50
+ const OUTGOING_RUN_DELIMITERS = /(\s+)/;
49
51
  export function redactSecrets(text) {
50
52
  const targeted = text
51
53
  .replace(/\b(bearer|basic)(\s+)\S+/gi, "$1$2[redacted-secret]")
54
+ .split(OUTGOING_RUN_DELIMITERS)
55
+ .map((run, i) => (i % 2 === 0 && carriesUserinfo(run) ? "[redacted-secret]" : run)) // a URL candidate segment carrying userinfo ⇒ the whole segment (S-811)
56
+ .join("")
52
57
  .replace(/[A-Za-z0-9_./:+=%-]{6,}/g, (tok) => (containsSecretToken(tok) ? "[redacted-secret]" : tok));
58
+ // What could not be normalized does not go out: the scanner counts an unfinished normalization as secret-shaped, so such a run is
59
+ // either replaced by the targeted pass above or still in `targeted` — where `containsSecretToken` withholds the whole string.
60
+ // A credential pair only visible on a normalized form (percent-encoded `https%3A%2F%2Fu%3Apw%40h`) withholds the whole string too.
53
61
  const scan = secretScanForms(text);
54
62
  const targetedForms = targeted === text ? scan.forms : secretScanForms(targeted).forms; // 没替换过(最常见)⇒ 同一组形,不再归一化一遍
55
- const whole = scan.unresolved || scan.forms.some((form) => PRIVATE_KEY_PEM_HEADER.test(form)) || targetedForms.some((form) => containsSecretToken(form));
63
+ const whole = scan.forms.some((form) => PRIVATE_KEY_PEM_HEADER.test(form)) || containsSecretToken(targeted) || targetedForms.some((form) => carriesUserinfo(form));
56
64
  return whole ? "[redacted-secret]" : targeted;
57
65
  }
58
- /** Sanitize a ZodError IN PLACE so no surfaced error (message / `received` / nested `unionErrors`) echoes a
59
- * pasted secret. RECURSES into `unionErrors` (a `z.union` failure nests a ZodError per member — e.g. RoleTarget
60
- * `{model}|{select}`) and redacts string path segments too. The ONE sanitizer every validation entry point
61
- * uses (parseDomain / validateDomain / validateRemoteExec / write*). R14. */
66
+ /** Sanitize a ZodError IN PLACE so no surfaced error (message / any issue field / nested issues) echoes a pasted secret.
67
+ * Every string an issue carries is redacted, string path segments included, and the walk RECURSES into the issues zod nests
68
+ * inside an issue — `invalid_union.errors` (one issue list per union member, e.g. RoleTarget `{model}|{select}`) and
69
+ * `invalid_key` / `invalid_element`.issues — because a nested issue's message can echo the same value. Non-string path
70
+ * segments other than numbers (zod types a path as `PropertyKey[]`) leave as their redacted `String()` form, so a path read
71
+ * off a sanitized error is always `string | number`. The error's `message` is re-derived from the
72
+ * sanitized issues at the end (zod caches it on first read, so an earlier read would otherwise keep the raw text).
73
+ * The ONE sanitizer every validation entry point uses (parseDomain / validateDomain / validateRemoteExec / write*). R14. */
62
74
  export function sanitizeZodError(err) {
63
- for (const iss of err.issues) {
64
- const rec = iss;
65
- for (const k of Object.keys(rec)) {
66
- const v = rec[k];
67
- if (typeof v === "string")
68
- rec[k] = redactSecrets(v);
69
- else if (Array.isArray(v)) {
70
- rec[k] = v.map((el) => {
71
- if (typeof el === "string")
72
- return redactSecrets(el);
73
- if (el && typeof el === "object" && Array.isArray(el.issues))
74
- return sanitizeZodError(el); // nested unionErrors
75
- return el;
76
- });
77
- }
78
- }
79
- }
75
+ for (const iss of err.issues)
76
+ sanitizeIssueFields(iss, 0);
77
+ err.message = JSON.stringify(err.issues, null, 2);
80
78
  return err;
81
79
  }
80
+ /** Nesting bound for {@link sanitizeIssueFields}: zod nests one level per union / key / element boundary; anything deeper than
81
+ * this is not a shape zod produces, and a value the walk does not reach is withheld rather than passed through. */
82
+ const SANITIZE_MAX_DEPTH = 32;
83
+ function sanitizeIssueFields(rec, depth) {
84
+ if (typeof rec.code === "string" && typeof rec.message === "string")
85
+ boundIssueIdentifiers(rec);
86
+ for (const k of Object.keys(rec))
87
+ rec[k] = sanitizeIssueValue(rec[k], depth + 1);
88
+ }
89
+ /** S-815(11.0.0):一条 issue 里**用户写的标识串** —— 坐标的字符串段(`path`)、zod 回显的未知键(`keys`)、自定义问题句里的操作数
90
+ * (`params.operands`,{@link QuotedIssueParams})—— 出门前过告警坐标段同一道 {@link outgoingSegment}(长过 256 ⇒ 整段扣,否则脱敏),
91
+ * 句子由同一组出门形重铸:带 `params` 的按模板重渲染({@link renderQuotedSentence}),zod 自己的句子把每个改过的原串换成它的出门形
92
+ * (长的先换)。之后整条 issue 照旧过逐字段脱敏。改前只有告警坐标段有上界:同一个 300 字的坏键在坐标里读 `[redacted-secret]`、
93
+ * 在 `keys` / `message` 里原样 —— 一条告警两种读法。 */
94
+ function boundIssueIdentifiers(rec) {
95
+ const changed = new Map();
96
+ const bound = (v) => {
97
+ if (typeof v !== "string")
98
+ return v;
99
+ const out = outgoingSegment(v);
100
+ if (out !== v)
101
+ changed.set(v, out);
102
+ return out;
103
+ };
104
+ if (Array.isArray(rec.path))
105
+ rec.path = rec.path.map(bound);
106
+ if (Array.isArray(rec.keys))
107
+ rec.keys = rec.keys.map(bound);
108
+ const params = rec.params;
109
+ if (params !== undefined && Array.isArray(params.template) && Array.isArray(params.operands)) {
110
+ const quoted = { template: params.template, operands: params.operands.map((o) => bound(o)) };
111
+ rec.params = { ...params, ...quoted };
112
+ rec.message = renderQuotedSentence(quoted);
113
+ return;
114
+ }
115
+ let message = rec.message;
116
+ for (const [raw, out] of [...changed].sort((a, b) => b[0].length - a[0].length))
117
+ message = message.split(raw).join(out);
118
+ rec.message = message;
119
+ }
120
+ function sanitizeIssueValue(v, depth) {
121
+ if (typeof v === "string")
122
+ return redactSecrets(v);
123
+ if (typeof v === "symbol")
124
+ return redactSecrets(String(v));
125
+ if (v === null || typeof v !== "object")
126
+ return v;
127
+ if (depth > SANITIZE_MAX_DEPTH)
128
+ return "[redacted-secret]";
129
+ if (Array.isArray(v))
130
+ return v.map((el) => sanitizeIssueValue(el, depth + 1));
131
+ sanitizeIssueFields(v, depth);
132
+ return v;
133
+ }
82
134
  export const ROLE_RANK = { viewer: 0, editor: 1, publisher: 2, admin: 3 };
83
135
  export const USER_ROLES = ["viewer", "editor", "publisher", "admin"];
84
136
  /** True if `role` is at least `min` in the hierarchy (e.g. publisher satisfies editor). */
@@ -107,21 +159,21 @@ export const DOMAIN_WRITE_ROLE = {
107
159
  // systems drive CREDENTIAL minting/delivery/rotation (S2/S3) — the credential lifecycle is ≥ the skills tier.
108
160
  systems: "publisher",
109
161
  // access: DELETED in 0.6.0 (域整删,EXPERT-REDESIGN §9)。
110
- // governance (治理三件,0.6.0 从 runtime 拆出): editor(EXPERT-REDESIGN §5 domainWriteRole 同步——与
111
- // runtime 残余同档,default 即 editor,故不列条目;此注释即该裁定的落点)。
162
+ // governance (治理三件,0.6.0 从 runtime 拆出): editor(EXPERT-REDESIGN §5 domainWriteRole 同步;
163
+ // default 即 editor,故不列条目;此注释即该裁定的落点)。
112
164
  // entitlement governs who-can-USE-what (models/skills/mcp/budget/runtime caps) per principal — a governance +
113
165
  // security surface (budget ceilings, bypass kill-switch) on par with systems. Same raised gate.
114
166
  entitlement: "publisher",
115
167
  // prompts (0.10.19, [ref]③/②d): declaration text goes STRAIGHT into the system prompt of
116
168
  // every worker×scenario the binding covers — the same injection surface (and rationale) as skills/mcp,
117
- // so the same raised gate. Center had pre-raised this locally per governance precedent; this row is the
169
+ // so the same raised gate. Web-admin had pre-raised this locally per governance precedent; this row is the
118
170
  // contract-side source of truth so every consumer of the shared write gate gets publisher, not editor.
119
171
  prompts: "publisher",
120
172
  // execution (0.8.0): editor — same档 as governance (治理旋钮同类;真安全边界在 service gate,
121
173
  // client-advisory 面不值 publisher 档)。default 即 editor,故不列条目;此注释即该裁定的落点。
122
174
  // projects (0.10.0, 142-S3): editor — 登记簿是身份索引不是注入面(无 prompt/凭据/可执行内容);
123
175
  // mint/claim 动作端点与编辑面同档(草案 §3 "该 scope editor+")。default 即 editor,故不列条目。
124
- // limits (0.19.0): editor —— 与 runtime(它的限额残余前身)同档,刻意不抬 publisher:本域是纯数值
176
+ // limits (0.19.0): editor —— 与它的前身 runtime 域(11.0.0 retired)同档,刻意不抬 publisher:本域是纯数值
125
177
  // 旋钮,无 prompt 注入面、无凭据、无可执行内容;真正的执法点在消费方的限额闸,发布闸(PUBLISH_MODE /
126
178
  // 审批流)才是"要不要多一道人"的属主。default 即 editor,故不列条目;此注释即该裁定的落点。
127
179
  };
@@ -135,8 +187,39 @@ export function domainWriteRole(domain) {
135
187
  export function emptyEffective() {
136
188
  return buildEffective(0, () => undefined, new Date(0).toISOString());
137
189
  }
190
+ /**
191
+ * 一个**retired 域**的文档带着事实 —— 读存储文档的每个读者同一只拒(`parseDomain` 写、`buildEffective` 向店要、`readEffectiveWire` 看 wire、
192
+ * `FileConfigStore` 与 `readBundle` 读 `config.d/`)。`message` 是墓碑表那一行的整句(点名retired版本、事实今天住哪、改写存量的一次性迁移);
193
+ * `code` 是机读码,`domain` / `migration` 是那一行的两格。墓碑是拒,不是读法:它从不把旧文档读成任何值。
194
+ */
195
+ export class RetiredDomainError extends Error {
196
+ domain;
197
+ code = "retired_domain";
198
+ migration;
199
+ constructor(domain) {
200
+ super(retiredDomainSentence(domain));
201
+ this.domain = domain;
202
+ this.name = "RetiredDomainError";
203
+ this.migration = RETIRED_DOMAINS[domain].migration;
204
+ }
205
+ }
206
+ /** 一份retired 域文档**带不带事实**:缺席与 `{}` 不带(没有可丢的东西,读者不理会);其余一切(含 `null` / 非对象 / 有键的对象)都带 ⇒ 拒。 */
207
+ function carriesFacts(raw) {
208
+ if (raw === undefined)
209
+ return false;
210
+ return !(isPlainJsonObject(raw) && Object.keys(raw).length === 0);
211
+ }
212
+ /** 墓碑判一次:`raw` 是retired 域 `domain` 的文档,带事实 ⇒ 抛 {@link RetiredDomainError}。 */
213
+ export function refuseRetiredDomain(domain, raw) {
214
+ if (carriesFacts(raw))
215
+ throw new RetiredDomainError(domain);
216
+ }
138
217
  /** Validate + fill defaults for a domain payload (or the schema's empty default when absent/invalid). */
139
218
  export function parseDomain(domain, raw) {
219
+ // A retired domain is refused by its tombstone (one sentence naming the one-time migration), not as "not a domain":
220
+ // a writer still submitting it needs to know where its keys went.
221
+ if (isRetiredDomainName(domain))
222
+ throw new RetiredDomainError(domain);
140
223
  // A name that is not a domain is refused for what it is — before, `DOMAIN_SCHEMAS[name]` was undefined and the
141
224
  // TypeError surfaced through the guard below as "a value could not be read", which names the wrong failure. The
142
225
  // strict-write face hands this function every key of a submitted document set, so the name is user text: it leaves
@@ -155,41 +238,7 @@ export function parseDomain(domain, raw) {
155
238
  }
156
239
  if (r.success)
157
240
  return r.data;
158
- throw sanitizeZodError(r.error); // recursive redaction (message/received/unionErrors) — no secret echo. R13/R14.
159
- }
160
- // ── 逐条容错律(2.0.0,S-144 按类修)───────────────────────────────────────────────────────────────
161
- //
162
- // 病:域解析整域 all-or-nothing —— 13 个模型里坏一条,整张目录退成 schema 默认 = 一条都不剩。缓解一直
163
- // 是**逐键**打补丁(把新键改成 `z.unknown()` 让它别拒),那是给每个新键加特判,规则只会越来越多;
164
- // 而本仓的调度存储早就有成熟的逐条容错形(坏记录逐条丢 + 逐条上报,其余全存活),只是没抬到域面。
165
- //
166
- // 本节把那条判据提成**域无关的单铸点**:`salvageEntries(domain, raw)` 从域 schema 自身派生出「哪些字段
167
- // 是条目集合」(数组 / Record),逐条 parse,坏的丢掉并点名。域面唯一的调用点是 `buildEffective` 的
168
- // `parseOr`,且**只在整域解析已经失败之后**才跑 —— 好配置的解析路径一字不动(与 0.8.1 的 grandfather
169
- // 同姿势:先精确、后通用、成功路径 byte 不变)。
170
- //
171
- // 🔴 不新开档位:容错只发生在 {@link DOMAIN_READ_FALLBACK} 判 `"default"` 的域。gate 域(rosters /
172
- // governance / entitlement / execution / workers / runtime / limits / projects)坏条目仍然 throw ——
173
- // 悄悄丢掉一条 roster 就是那个 worker 静默拿到全量目录,与整域降级是同一条「放权,不许」的理由,所以
174
- // 共用同一张表,不为容错另立一张。
175
- /** 展开 zod 的包装层(`.optional()` / `.default()` / `.nullable()` / `.catch()` / `.transform()` /
176
- * `.superRefine()`),拿到里面真正的那个 schema。包装不改变「条目能不能单独解析」这件事。 */
177
- function unwrapSchema(schema) {
178
- let cur = schema;
179
- // 有界:包装层数是 schema 作者写出来的常数,8 层远超实际(防被畸形 schema 拖住)。
180
- for (let i = 0; i < 8; i++) {
181
- if (cur instanceof z.ZodEffects)
182
- cur = cur.innerType();
183
- else if (cur instanceof z.ZodOptional || cur instanceof z.ZodNullable)
184
- cur = cur.unwrap();
185
- else if (cur instanceof z.ZodDefault)
186
- cur = cur.removeDefault();
187
- else if (cur instanceof z.ZodCatch)
188
- cur = cur.removeCatch();
189
- else
190
- return cur;
191
- }
192
- return cur;
241
+ throw sanitizeZodError(r.error); // recursive redaction (message / every issue field / nested issues) — no secret echo. R13/R14.
193
242
  }
194
243
  /** 一个**逐字出门**的字符串段的样子 —— 一条判据,不是一串特例:**逐字写出去之后读回来只能是它自己**。
195
244
  * 所以四类字符串段必须改走引号形:空串、含 `.`(段分隔符)、含 `"`(引号形自身)或控制字符的,以及
@@ -207,17 +256,19 @@ const PLAIN_PATH_SEGMENT = /^(?!(?:0|[1-9]\d*)$)[^."\u0000-\u001f]+$/;
207
256
  export function formatConfigPath(p) {
208
257
  return p.map((seg) => (typeof seg === "number" ? String(seg) : PLAIN_PATH_SEGMENT.test(seg) ? seg : JSON.stringify(seg))).join(".");
209
258
  }
210
- /** 本文件铸的、带用户写的键段的告警坐标出门前的**唯一**一道(6.3.0,codex r1 F2):字符串段过 {@link outgoingSegment} —— 与 `error` 文案
259
+ /** 带用户写的键段的坐标出门前的**唯一**一道(6.3.0,codex r1 F2):字符串段过 {@link outgoingSegment} —— 与 `error` 文案
211
260
  * ({@link sanitizeZodError})、身份否决({@link isDisclosableIdentity})**同一只**脱敏器,不另设判据;数字段(下标)原样。
212
261
  * 键段是用户写的,完全可能就是一条粘错位置的凭据:被丢 Record 条目的键、未知键告警(`unknown-keys-dropped` /
213
262
  * `-carried`)的键名都一样(后者 0.19.0 起从不过脱敏器,`sk-…` 形的未知键名原样进告警与投影)。schema 已知的字段名
214
263
  * 脱敏器原样放行,所以坐标里「它是哪个字段」照旧可读。经它的坐标就是出门形 —— {@link droppedKeys} /
215
- * {@link carriedUnknownKeys} 这两只导出函数给消费方的 `keys` 同样是它的产物。`legacy-governance-lifted.keys` 不经它:那些键只来自
216
- * 闭集 {@link GOVERNANCE_MIRROR_KEYS},按构造没有用户写的串(opus 腿 I1)。 */
217
- function outgoingPath(p) {
264
+ * {@link carriedUnknownKeys} 这两只扫描交给告警的 `keys` 同样是它的产物。
265
+ *
266
+ * 11.0.0 起导出:读告警之外还有两处铸坐标的面 —— 一次性迁移的收据(`./one-time-migrations`)与 workers 的逐行终判(`readWorkersRows`
267
+ * 的 `held[].field`),它们与告警走同一道,不另抄一份判据;消费方自己拼坐标出门(日志 / 界面)时也调它。 */
268
+ export function outgoingPath(p) {
218
269
  return p.map((seg) => (typeof seg === "string" ? outgoingSegment(seg) : seg));
219
270
  }
220
- /** 一段用户写的串(坐标键段、`hosts-grandfathered` 被夹紧的主机名)出门前的那一道:长过 {@link DISCLOSABLE_IDENTITY_MAX}(与身份白名单同一只上界)
271
+ /** 一段用户写的串(坐标键段、域名)出门前的那一道:长过 {@link DISCLOSABLE_IDENTITY_MAX}(与身份白名单同一只上界)
221
272
  * ⇒ 不扫描、整段 `[redacted-secret]`(标识符不会那么长;fail-closed 且 O(1) —— opus 独立腿 M1:6.3.0 首稿让每个未知键名
222
273
  * 不论多长都过脱敏器,64 个 16 KB 的键名把一次读拖到 20 s 以上);否则过 {@link redactSecrets}。 */
223
274
  function outgoingSegment(seg) {
@@ -233,8 +284,7 @@ function outgoingSegment(seg) {
233
284
  * 为什么是词而不是 `error` 句:句子随 zod 小版本改字、可经脱敏,拿它当判据就是拿实现当契约;wire 上也不许有自由文本。
234
285
  * 这张表由 `test/dropped-entry-reason-code-s1295.test.ts` 的两格咬住:穷举 `switch`(给表加词而不补臂 ⇒ `tsc` 红,门 = `npm run typecheck`)与
235
286
  * 满射格(每个词至少有一个真丢弃点触发它 = 没有死词;每个被丢条目的词都在表里 = 没有无词的丢弃点)。
236
- * 边界(如实登记):本表只管 `salvageEntries` 铸的 {@link DroppedEntry};读路径上另一条按名剔条目的臂
237
- * (`hosts-grandfathered`,主机名超长)走它自己的告警臂,不是 `DroppedEntry`,不在本表。
287
+ * 读路径上剔条目的只有 `salvageEntries` 这一处(11.0.0 起 hosts 的读侧夹紧臂已retired),所以本表覆盖读路径上每一条被丢的条目。
238
288
  */
239
289
  export const DROPPED_ENTRY_REASON_CODES = ["entry-schema-invalid", "entry-key-invalid", "entry-unreadable"];
240
290
  /** 可披露身份与出门坐标段的**长度上界**(一只常量,两处用:{@link DISCLOSABLE_IDENTITY_RE} 的上限、{@link outgoingSegment}
@@ -254,8 +304,8 @@ export const DISCLOSABLE_IDENTITY_RE = new RegExp(`^[A-Za-z0-9._:/@+-]{1,${DISCL
254
304
  * ② **否决**(「无密钥形」):坐标脱敏器 {@link redactSecrets} 原样放行 —— 集内照样有密钥形(`sk-…` / `ghp_…` /
255
305
  * `AKIA…` / 三段 JWT 的字符全在集内),而且判它的必须是坐标段、`error` 文案({@link sanitizeZodError})用的**同一只**
256
306
  * 脱敏器:两只判据会在同一条告警里给出两种答案(对抗轮 #2 真复现;集内的残例 = `@` 之后的单点 `eyJ…` 串,裸探测器
257
- * 判阴、脱敏器会改,语料 c41 钉着);∧ 不是带 userinfo 的 URL —— 校验侧 `NON_SECRET_URL` 拒写的同一只判定
258
- * (`carriesUserinfo`,包内私有;opus 腿 L1:`postgres://svc:pw@h` 的字符全在集内)。
307
+ * 判阴、脱敏器会改,语料 c41 钉着)。带 userinfo 的 URL(opus 腿 L1:`postgres://svc:pw@h` 的字符全在集内)由同一只脱敏器扣下
308
+ * (11.0.0 S-811 起它对 URL 候选段问 `carriesUserinfo`),不再单列一条否决。
259
309
  * 入参是 `string`、返回 `boolean`,**刻意不是**类型谓词(`v is string`):它对一些字符串答 false,谓词形会让调用方的
260
310
  * 否定分支把一个 `string` 收窄成 `never`;「是不是字符串」由调用点自己判(运行期收到非字符串照答 false、不抛)。
261
311
  * 不可披露的串按**无名**处置(不带,而不是打码:打过码的串不再是身份)。
@@ -263,7 +313,7 @@ export const DISCLOSABLE_IDENTITY_RE = new RegExp(`^[A-Za-z0-9._:/@+-]{1,${DISCL
263
313
  * (经 {@link isReportableRefusalName},S-768);引擎自己的日志 / 拒因句里要不要写出一个名字,同样问这一只。 */
264
314
  export function isDisclosableIdentity(v) {
265
315
  // 非字符串(JS / any 调用方)⇒ false,不抛(秘密轴 fail-closed)
266
- return typeof v === "string" && DISCLOSABLE_IDENTITY_RE.test(v) && redactSecrets(v) === v && !carriesUserinfo(v);
316
+ return typeof v === "string" && DISCLOSABLE_IDENTITY_RE.test(v) && redactSecrets(v) === v;
267
317
  }
268
318
  /** S-768 —— 「一个目录条目名能不能当 {@link ModelRefusalsWire} `entries` 的键」的**唯一**判据;引擎铸键前只调这一只,
269
319
  * 不过 ⇒ 按无名处置(不进键、不计数)。两条律的合取,不手抄任何一条:
@@ -291,8 +341,8 @@ function readEntryIdentity(entry) {
291
341
  const id = own("id");
292
342
  const enabled = own("enabled");
293
343
  const identity = {
294
- ...(typeof name === "string" && isDisclosableIdentity(name) ? { name } : {}),
295
- ...(typeof id === "string" && isDisclosableIdentity(id) ? { id } : {}),
344
+ ...(typeof name === "string" ? (isDisclosableIdentity(name) ? { name } : { nameWithheld: true }) : {}),
345
+ ...(typeof id === "string" ? (isDisclosableIdentity(id) ? { id } : { idWithheld: true }) : {}),
296
346
  ...(typeof enabled === "boolean" ? { enabled } : {}),
297
347
  };
298
348
  return Object.keys(identity).length > 0 ? identity : undefined;
@@ -409,8 +459,8 @@ function salvageEntriesUnsafe(schema, raw) {
409
459
  }
410
460
  }
411
461
  else if (collection instanceof z.ZodRecord && isPlainJsonObject(value)) {
412
- const keySchema = collection.keySchema;
413
- const valueSchema = collection.valueSchema;
462
+ const keySchema = collection.keyType;
463
+ const valueSchema = collection.valueType;
414
464
  const bad = [];
415
465
  const kept = {};
416
466
  for (const [key, entry] of Object.entries(value)) {
@@ -434,41 +484,21 @@ function salvageEntriesUnsafe(schema, raw) {
434
484
  * Shared GC predicate for worker-status rows (F10) — keeps memory/sqlite/tidb identical. Given the rows'
435
485
  * `{name, updatedAt}` and the GC options, returns the names to prune (A: not in `keepNames`; B: older than
436
486
  * `ttlMs`). Pass neither selector → prunes nothing (guards against a wipe-all). See `ConfigStore.gcWorkerStatuses`.
487
+ *
488
+ * `now` is REQUIRED (11.0.0): this package never reads the wall clock (the determinism rule in the file header) — the
489
+ * TTL arm used to fall back to `new Date()`, the one argless clock left in the package. The caller owns the time.
437
490
  */
438
491
  export function statusRowsToPrune(rows, opts) {
439
492
  if (opts.keepNames === undefined && opts.ttlMs === undefined)
440
493
  return [];
494
+ if (!(opts.now instanceof Date) || Number.isNaN(opts.now.getTime()))
495
+ throw new Error("statusRowsToPrune: opts.now must be a valid Date (the caller owns the clock)");
441
496
  const keep = opts.keepNames ? new Set(opts.keepNames) : undefined;
442
- const cutoff = opts.ttlMs !== undefined ? (opts.now ?? new Date()).getTime() - opts.ttlMs : undefined;
497
+ const cutoff = opts.ttlMs !== undefined ? opts.now.getTime() - opts.ttlMs : undefined;
443
498
  return rows
444
499
  .filter((r) => (keep !== undefined && !keep.has(r.name)) || (cutoff !== undefined && Date.parse(r.updatedAt) < cutoff))
445
500
  .map((r) => r.name);
446
501
  }
447
- /**
448
- * Merge every domain into the effective config a consumer pulls. Shared by all store impls.
449
- * `updatedAt` is REQUIRED (the determinism refactor — the original store.ts defaulted it to the wall clock,
450
- * which made `emptyEffective`/the memory store non-deterministic). Every caller now supplies it explicitly.
451
- */
452
- /** Lift the governance trio out of a LEGACY runtime doc (pre-0.6 stored runtime carried autonomy/commandPolicy/
453
- * approvalRequire). Used as the governance domain's read fallback when governance was NEVER set — so a 0.5
454
- * store upgrades with zero data migration and zero gate loss. An explicitly-set governance domain WINS
455
- * (its absence-of-a-key then means "center not managing", never "fall back to legacy runtime").
456
- *
457
- * SUNSET (2.0.0): removal is a **persistent-data** break — a store still carrying the 0.5 shape would lose
458
- * its guardrails silently, so this arm cannot simply be deleted at a minor. It goes at the next MAJOR, with
459
- * the standard persistent-data treatment (a loud refusal naming the domain + a one-time migration on the
460
- * writer side), and the closing condition is observable rather than a promise: `buildEffective` now emits
461
- * `legacy-governance-lifted` every time the arm actually fires, so a deployment can prove it no longer does. */
462
- export function legacyGovernanceFromRuntime(rawRuntime) {
463
- if (rawRuntime === null || typeof rawRuntime !== "object")
464
- return undefined;
465
- const r = rawRuntime;
466
- const lifted = {};
467
- for (const k of GOVERNANCE_MIRROR_KEYS)
468
- if (r[k] !== undefined)
469
- lifted[k] = r[k];
470
- return Object.keys(lifted).length ? lifted : undefined;
471
- }
472
502
  /** {@link EffectiveReadWarning} 的 `kind` **闭集**(运行期值)。一致性 kit 的覆盖律按它计数,消费方要遍历
473
503
  * 「所有告警种类」也用它 —— 属主在告警联合自己的模块里,`./conformance` 只再导出(4.0.0 前它住在 kit 里,
474
504
  * 那边是联合的下游,却握着联合的闭集)。
@@ -480,11 +510,9 @@ export function legacyGovernanceFromRuntime(rawRuntime) {
480
510
  * 进本表」这半条缝,由 `test/warning-projection-derived-4.0.0.test.ts` 的闭集格看着(本表 == 从 AST 解出的
481
511
  * 臂集,逐字同集同序)。 */
482
512
  export const WARNING_KINDS = [
483
- "hosts-grandfathered",
484
513
  "domain-defaulted",
485
514
  "unknown-keys-dropped",
486
515
  "unknown-keys-carried",
487
- "legacy-governance-lifted",
488
516
  "entry-dropped",
489
517
  "unread-config-file",
490
518
  ];
@@ -497,8 +525,6 @@ export const WARNING_KINDS = [
497
525
  */
498
526
  export function projectWarning(w) {
499
527
  switch (w.kind) {
500
- case "hosts-grandfathered":
501
- return { kind: w.kind, domain: w.domain, droppedNames: w.droppedNames, ...(w.droppedTotal === undefined ? {} : { droppedTotal: w.droppedTotal }), ...(w.truncatedFrom === undefined ? {} : { truncatedFrom: w.truncatedFrom }) };
502
528
  case "domain-defaulted":
503
529
  return { kind: w.kind, domain: w.domain };
504
530
  // 两臂字段相同但**不合并**:合并后 `kind` 塌成两员的联合,TS 就再也认不出它属于投影联合的哪一员
@@ -507,8 +533,6 @@ export function projectWarning(w) {
507
533
  return { kind: w.kind, domain: w.domain, keys: w.keys, ...(w.truncatedFrom === undefined ? {} : { truncatedFrom: w.truncatedFrom }) };
508
534
  case "unknown-keys-carried":
509
535
  return { kind: w.kind, domain: w.domain, keys: w.keys, ...(w.truncatedFrom === undefined ? {} : { truncatedFrom: w.truncatedFrom }) };
510
- case "legacy-governance-lifted":
511
- return { kind: w.kind, domain: w.domain, keys: w.keys };
512
536
  case "entry-dropped":
513
537
  return { kind: w.kind, domain: w.domain, path: w.path, ...(w.identity === undefined ? {} : { identity: w.identity }), ...(w.reasonCode === undefined ? {} : { reasonCode: w.reasonCode }) };
514
538
  case "unread-config-file":
@@ -537,19 +561,18 @@ export function projectWarning(w) {
537
561
  * **闭集穷举**:`switch` 覆盖 {@link EffectiveReadWarning} 的每个 `kind`,新 kind 不声明处置 = 编译红
538
562
  * (#157 的闭集纪律)。只分两类,没有第三类:**带坐标的回写,不带坐标的原样返回**。
539
563
  *
540
- * 为什么 `entry-dropped` 也回写:它**不是**「恒为原坐标」,只是不经**铸它那一层自己**的表 —— 上面那条律
541
- * 说的就是这件事。{@link salvageEntries} 铸 `entry-dropped` 时用的是它**入参**那份文档的坐标;如果那份
542
- * 文档本身已被外层(hosts 的 {@link grandfatherHostsRead})压缩过,外层照样要回写它一次。把「不动」写成
543
- * 一条臂的性质,就是把「哪一层」这件事错记在了「哪个 kind」上。
564
+ * 为什么 `entry-dropped` 也有回写臂:它**不是**「恒为原坐标」,只是不经**铸它那一层自己**的表 —— 上面那条律
565
+ * 说的就是这件事。{@link salvageEntries} 铸 `entry-dropped` 时用的是它**入参**那份文档的坐标;若那份文档本身被外层
566
+ * 压缩过,外层照样要回写它一次。今天读路径上只有一个压缩点(11.0.0 起 hosts 的读侧夹紧臂已retired),所以这一臂当下
567
+ * 只对 `keptIndex` 为空的表恒等;把「不动」写成一条臂的性质,就是把「哪一层」这件事错记在了「哪个 kind」上。
544
568
  *
545
569
  * **恒等纪律**:没有任何一条路径真的变了(含 `keptIndex` 为空)⇒ 返回**同一个对象引用**,连 `keys`
546
- * 数组也不复制(与 {@link grandfatherHostsRead} 的「没夹紧就返回原引用」同一条 byte 不变律)。
570
+ * 数组也不复制(好数据穿过读路径 byte 不变)。
547
571
  */
548
572
  export function rebaseWarningPaths(w, keptIndex) {
549
573
  switch (w.kind) {
550
574
  case "unknown-keys-dropped":
551
- case "unknown-keys-carried":
552
- case "legacy-governance-lifted": {
575
+ case "unknown-keys-carried": {
553
576
  const keys = rebasePaths(w.keys, keptIndex);
554
577
  return keys === w.keys ? w : { ...w, keys };
555
578
  }
@@ -557,8 +580,7 @@ export function rebaseWarningPaths(w, keptIndex) {
557
580
  const path = rebasePath(w.path, keptIndex);
558
581
  return path === w.path ? w : { ...w, path };
559
582
  }
560
- // 以下三臂不带任何坐标:`hosts-grandfathered` 点名的是主机**名**,另两条点名的是错误/文件名。
561
- case "hosts-grandfathered":
583
+ // 以下两臂不带任何坐标:点名的是错误 / 文件名。
562
584
  case "domain-defaulted":
563
585
  case "unread-config-file":
564
586
  return w;
@@ -654,22 +676,20 @@ function isPlainJsonObject(v) {
654
676
  const proto = Object.getPrototypeOf(v);
655
677
  return proto === Object.prototype || proto === null;
656
678
  }
657
- function collectDropped(domain, raw, parsed, prefix, depth, spared, acc) {
679
+ function collectDropped(domain, raw, parsed, prefix, depth, acc) {
658
680
  if (depth > DROP_SCAN_MAX_DEPTH)
659
681
  return;
660
682
  if (Array.isArray(raw) && Array.isArray(parsed)) {
661
- // 长度不同 = 整条元素被丢/被裁(hosts 的 grandfather clamp 有自己的警告),不在本判据内,别造重复噪声。
683
+ // 长度不同 = 整条元素被丢(`entry-dropped` 有自己的警告),不在本判据内,别造重复噪声。
662
684
  if (raw.length !== parsed.length)
663
685
  return;
664
686
  for (let i = 0; i < raw.length; i++)
665
- collectDropped(domain, raw[i], parsed[i], [...prefix, i], depth + 1, [], acc);
687
+ collectDropped(domain, raw[i], parsed[i], [...prefix, i], depth + 1, acc);
666
688
  return;
667
689
  }
668
690
  if (!isPlainJsonObject(raw) || !isPlainJsonObject(parsed))
669
691
  return;
670
692
  for (const k of Object.keys(raw)) {
671
- if (depth === 0 && spared.includes(k))
672
- continue;
673
693
  if (!Object.prototype.hasOwnProperty.call(parsed, k)) {
674
694
  if (isReservedExtensionKey(domain, prefix, k))
675
695
  continue; // 扩展命名空间:按契约放的键,不是未知键
@@ -678,38 +698,36 @@ function collectDropped(domain, raw, parsed, prefix, depth, spared, acc) {
678
698
  acc.keys.push(outgoingPath([...prefix, k]));
679
699
  continue;
680
700
  }
681
- collectDropped(domain, raw[k], parsed[k], [...prefix, k], depth + 1, spared, acc);
701
+ collectDropped(domain, raw[k], parsed[k], [...prefix, k], depth + 1, acc);
682
702
  }
683
703
  }
684
704
  /**
685
705
  * 一份域文档里被域 schema **丢掉的键**(zod 对象默认 `"strip"`),段坐标形({@link ConfigPath})。
686
706
  *
687
- * 成案(消费方 #322 真事故):往 `runtime` 文档里加一个不属于该域的键,两条腿都会把它剥掉,
707
+ * 成案(消费方 #322 真事故):往一份域文档里加一个不属于该域的键,两条腿都会把它剥掉,
688
708
  * `domainErrors` 空、解析"成功"——配置写了、值没到、零告警。剥除行为本身是**刻意的前向兼容**
689
- * (新 center 的新键不该炸老消费方),所以修法不是收紧成 `.strict()`,而是让它**响亮**:
709
+ * (新 web-admin 的新键不该炸老消费方),所以修法不是收紧成 `.strict()`,而是让它**响亮**:
690
710
  * {@link buildEffective} 每域解析成功后调用本函数,非空即发 `"unknown-keys-dropped"` 警告。
691
711
  *
692
712
  * **嵌套也扫**(对抗复审 复审 F2 真 finding):`{infraCostRates:{toolCalMicroUsd:10}}` 这种**拼错的嵌套
693
713
  * 键**在顶层看不见(`infraCostRates` 键还在),只看顶层等于对计费面的错键完全失明。故按 raw↔parsed
694
714
  * 的键集逐层对比(对象逐键、数组同长时逐下标),不依赖任何 zod 内省。
695
715
  *
696
- * `runtime` 域的治理三件({@link GOVERNANCE_MIRROR_KEYS})**不算被丢**:0.5 期 runtime 文档携带它们是
697
- * 成文的抬升位(本文件的 governance 抬升函数会把它们抬进 governance 域),报出来是噪声。豁免只在顶层。
716
+ * 零豁免(11.0.0):此前 `runtime` 域顶层的治理三件不算被丢(读侧抬升臂的成文位);抬升臂与 `runtime` 域一起retired,豁免随之删。
698
717
  *
699
- * 返回 {@link UnknownKeyScan}:`keys` 是**有界**的点名列表,`total` 是截断前的真实总数(4.0.0 BREAKING,
700
- * 此前只回 `keys` —— 见 {@link UnknownKeyScan} 的成案)。本包自己的告警(`unknown-keys-dropped`)也是拿这两个
701
- * 数拼出来的,**一个铸点**:消费方自铸的 `truncatedFrom` 与告警里那个恒等。
718
+ * 返回 {@link UnknownKeyScan}:`keys` 是**有界**的点名列表,`total` 是截断前的真实总数;告警(`unknown-keys-dropped`)
719
+ * 的 `keys` / `truncatedFrom` 就是拿这两个数拼出来的(模块私有,见 {@link UnknownKeyScan})。
702
720
  *
703
721
  * 边界(如实登记):① 深度上限 {@link DROP_SCAN_MAX_DEPTH}、键数上限 {@link DROP_SCAN_MAX_KEYS}(告警
704
- * 载荷有界,超出即少报**点名**——`total` 不少报);② 数组长度不同就整段跳过(元素级裁剪由各自的 clamp 告警
722
+ * 载荷有界,超出即少报**点名**——`total` 不少报);② 数组长度不同就整段跳过(元素级剔除由 `entry-dropped`
705
723
  * 负责);③ 只比对纯 JSON 对象——raw 若是类实例/Proxy 这类形状不扫;④ 读 raw 的值可能触发访问器,整段包
706
724
  * try/catch 且**绝不**让其错误外泄(错误文案可能带 secret,与 parseDomain 的 R13 同纪律),扫描失败即当作
707
725
  * "没看见"(`{ keys: [], total: 0 }`),不影响解析。
708
726
  */
709
- export function droppedKeys(domain, raw, parsed) {
727
+ function droppedKeys(domain, raw, parsed) {
710
728
  const acc = { keys: [], total: 0 };
711
729
  try {
712
- collectDropped(domain, raw, parsed, [], 0, domain === "runtime" ? GOVERNANCE_MIRROR_KEYS : [], acc);
730
+ collectDropped(domain, raw, parsed, [], 0, acc);
713
731
  }
714
732
  catch {
715
733
  return { keys: [], total: 0 }; // 反射/访问器抛错:告警是尽力而为的审计线,绝不能把解析拖下水,更不能回显其错误文案
@@ -737,14 +755,13 @@ const OPEN_WORLD_KNOWN_KEYS = {
737
755
  * 一份已解析的 open-world 域文档里,**本包不认识但已承运**的键(段坐标)。非 open-world 域恒空
738
756
  * (它们的未知键走 {@link droppedKeys} 那条更重的「被丢了」告警)。
739
757
  *
740
- * 返回 {@link UnknownKeyScan}(4.0.0 BREAKING,与 {@link droppedKeys} 同形同律:`keys` 有界、`total` 是
741
- * 截断前的真实总数)。非 open-world 域返回的也是这个形(`{ keys: [], total: 0 }`),不是别的东西 ——
742
- * 「这个域没有承运未知键」与「这个域不参与承运」在消费方那里是同一个处置。
758
+ * 返回 {@link UnknownKeyScan}(与 {@link droppedKeys} 同形同律:`keys` 有界、`total` 是截断前的真实总数;模块私有)。
759
+ * 非 open-world 域返回的也是这个形(`{ keys: [], total: 0 }`):「这个域没有承运未知键」与「这个域不参与承运」是同一个处置。
743
760
  *
744
761
  * 边界(如实):只看 {@link OPEN_WORLD_KNOWN_KEYS} 登记了的层级 —— 未登记层级(如某个未知键自己的
745
762
  * 子树)不再下探,那本就是消费方自定义的内容,逐层猜没有意义。
746
763
  */
747
- export function carriedUnknownKeys(domain, parsed) {
764
+ function carriedUnknownKeys(domain, parsed) {
748
765
  const table = OPEN_WORLD_KNOWN_KEYS[domain];
749
766
  const acc = { keys: [], total: 0 };
750
767
  if (table === undefined)
@@ -790,14 +807,12 @@ export function parseDomainLoud(domain, raw, onWarning) {
790
807
  * this call explicitly (compile error until it's classified). Per-domain rationale:
791
808
  * - models/skills/mcp/plugins/scenarios/systems/collab: additive capability catalogs — empty = the
792
809
  * capability disappears (fail-closed), no gate is lost.
793
- * - hosts: empty registry = no auto-placement targets (availability-only); self-registration re-lands
794
- * entries as approved:false = 待批 (fail-closed). Plus the precise grandfather clamp runs FIRST.
810
+ * - hosts: empty host registry = no auto-placement targets (availability-only); self-registration re-lands
811
+ * entries as approved:false = 待批 (fail-closed).
795
812
  * - rosters: THROW — a lost roster makes resolveEffectiveForWorker fall back to the FULL models catalog
796
813
  * ("global(fallback)"), silently WIDENING every restricted worker's model access. 放权,不许.
797
814
  * - workers: THROW — an empty fleet spec would make the reconciler RETIRE every managed worker
798
815
  * (destructive apply — far worse than a read outage).
799
- * - runtime: THROW — defaulting drops centrally-published rate/cost ceilings = unbounded spend (fail-open
800
- * on the budget axis).
801
816
  * - governance: THROW — defaulting drops autonomy/commandPolicy/approvalRequire = the consumer falls back
802
817
  * to env/defaults with the operator's guardrails silently gone. 放权,不许.
803
818
  * - entitlement: THROW — defaulting drops the org kill-switch/budget ceilings/tier restrictions for every
@@ -821,7 +836,6 @@ export const DOMAIN_READ_FALLBACK = {
821
836
  collab: "default",
822
837
  workers: "throw",
823
838
  hosts: "default",
824
- runtime: "throw",
825
839
  governance: "throw",
826
840
  entitlement: "throw",
827
841
  execution: "throw",
@@ -830,80 +844,33 @@ export const DOMAIN_READ_FALLBACK = {
830
844
  // backfill-empty-default 同口径);fail-closed 由 PUBLISH_MODE 发布闸承担,default 不扩权
831
845
  // (空 bindings=不下发任何内容)。
832
846
  prompts: "default",
833
- // limits(0.19.0):THROW —— 与 runtime 逐字同判据(本域就是 runtime 限额残余的继任发布位)。坏形
847
+ // limits(0.19.0):THROW —— 本域是成本 / 限流天花板的唯一发布位(11.0.0 起 runtime 域retired)。坏形
834
848
  // 降级成空域 = 中央发布的成本/限流天花板整片蒸发,消费方回自己的 env 底(可能宽得多)= 预算轴放权。
835
849
  // 消费方 fail-static 在上一份好配置严格更好。
836
850
  limits: "throw",
837
851
  };
838
- /** READ-path grandfather clamp for the 0.8.0 hosts tightening (0.8.1, S3 M-1). Clamps ONLY the two bounds
839
- * 0.8.0 added (name > 63 → drop the entry; array > 1024 → slice) so 0.7-era legal stored data keeps reading;
840
- * everything else (regex, types, duplicates) is left for parseDomain — this is a targeted grandfather, not a
841
- * lenient parser. PURE + idempotent; returns the ORIGINAL reference when nothing exceeds the bounds.
852
+ /**
853
+ * Merge every domain into the effective config a consumer pulls. Shared by all store impls.
854
+ * `updatedAt` is REQUIRED (the determinism refactor — the original store.ts defaulted it to the wall clock,
855
+ * which made `emptyEffective`/the memory store non-deterministic). Every caller now supplies it explicitly.
842
856
  *
843
- * SUNSET (2.0.0): same persistent-data class as the governance lift — it goes at the next MAJOR. This one was
844
- * never silent (`hosts-grandfathered` fires whenever it clamps), so the closing condition is already
845
- * observable: a deployment that has not seen that warning across a full read cycle no longer needs the arm. */
846
- export function grandfatherHostsRead(raw) {
847
- if (raw === null || typeof raw !== "object" || !Array.isArray(raw.hosts)) {
848
- return { raw, droppedNames: [], droppedTotal: 0, keptIndex: NO_ENTRIES_DROPPED }; // not the expected shape — let parseDomain rule on it
849
- }
850
- const original = raw.hosts;
851
- const droppedNames = [];
852
- let droppedTotal = 0;
853
- const keptFrom = [];
854
- let hosts = original.filter((h, i) => {
855
- const name = h !== null && typeof h === "object" ? h.name : undefined;
856
- if (typeof name === "string" && name.length > HOST_NAME_MAX) {
857
- droppedTotal += 1;
858
- // 先钳后扫(S-813):只给前 DROP_SCAN_MAX_KEYS 个铸标签,其余只计数 —— 出门那一道每个名都要归一化一遍,不能随文档条数放大。
859
- if (droppedNames.length >= DROP_SCAN_MAX_KEYS)
860
- return false;
861
- // 出门前先过同一道(6.3.0,opus 腿 M2):用户写的主机名完全可能就是一条粘错位置的凭据,夹紧只管载荷上界、不管秘密 ——
862
- // 此前 `sk-live-…` 形 / 编码 PEM 形以 63 字原文前缀出门。判在夹紧**之前**(截断会把秘密形截成探测器认不出的半截)。
863
- const out = outgoingSegment(name);
864
- droppedNames.push(out.length > HOST_NAME_MAX ? out.slice(0, HOST_NAME_MAX) + "…" : out); // audit label — clipped so the warning itself stays bounded
865
- return false;
866
- }
867
- keptFrom.push(i); // 夹紧后第 keptFrom.length-1 条 = 原文档第 i 条
868
- return true;
869
- });
870
- let truncatedFrom;
871
- if (hosts.length > HOSTS_ARRAY_MAX) {
872
- truncatedFrom = hosts.length;
873
- hosts = hosts.slice(0, HOSTS_ARRAY_MAX);
874
- }
875
- if (droppedTotal === 0 && truncatedFrom === undefined)
876
- return { raw, droppedNames, droppedTotal, keptIndex: NO_ENTRIES_DROPPED }; // identity — byte-stable
877
- // 位移**只**来自按名剔条目;纯截断留下的是原数组前缀,幸存者下标不动 ⇒ 不登记(见 EntryKeptIndex)。
878
- const keptIndex = droppedTotal > 0 ? { hosts: keptFrom.slice(0, hosts.length) } : NO_ENTRIES_DROPPED;
879
- return { raw: { ...raw, hosts }, droppedNames, droppedTotal, truncatedFrom, keptIndex };
880
- }
857
+ * 11.0.0:`getRaw` 也会被问到墓碑表里的retired 域(今天只有 `runtime`)。店里还留着一份**带事实**的retired 域文档 ⇒ 整份 effective 抛
858
+ * {@link RetiredDomainError}(拒句点名改写它的一次性迁移);缺席或 `{}` ⇒ 不理会。读写两面同一只 schema:读路径上不再有任何抬升臂。
859
+ */
881
860
  export function buildEffective(version, getRaw, updatedAt, onWarning) {
861
+ // 墓碑先判:一份没迁的retired 域文档意味着这个 scope 的存量还没过一次性迁移 —— 拿剩下的域拼一份 effective 等于静默丢掉它的事实。
862
+ for (const retired of Object.keys(RETIRED_DOMAINS))
863
+ refuseRetiredDomain(retired, getRaw(retired));
882
864
  // 0.19.0 静默剥键/静默承运门:解析**成功**的域,把 schema 丢掉的键、以及 open-world 域里承运但本包
883
- // 不认识的键(都含嵌套,点号路径)点名(行为不变,只是不再无声)。判据与 `getDomain` 同源
865
+ // 不认识的键(都含嵌套,段坐标)点名(行为不变,只是不再无声)。判据与 `getDomain` 同源
884
866
  // ({@link parseDomainLoud})。只在成功路径上报——降级路径已经有 domain-defaulted 那条更重的警告。
885
867
  const parseLoud = (domain, raw) => parseDomainLoud(domain, raw, onWarning);
886
- /**
887
- * 一层读的**告警出口**,外面裹了那一层的坐标换算(S-323b 统一律)。
888
- *
889
- * 读路径上有**两个压缩点**:{@link grandfatherHostsRead}(parse 之前按名剔 host 条目)与
890
- * {@link salvageEntries}(整域 parse 失败后剔坏条目)。律只有一条:**一条告警用「铸它的那一层所见
891
- * 文档」的坐标表达;哪一层压缩了文档,哪一层就把从它下方上来的告警回写一次。** 组合因此是自然的
892
- * ——内层先换算、外层后换算,不重不漏,而每一层都只需要知道**自己**那本账。
893
- *
894
- * 位移表**空**(这一层这次什么都没压缩)⇒ 原样返回 `onWarning`,连一层闭包都不套 —— 与
895
- * {@link rebaseWarningPaths} 的恒等纪律同一条律:没发生的事不留痕迹。
896
- */
897
- const exitOf = (keptIndex) => onWarning === undefined || Object.keys(keptIndex).length === 0 ? onWarning : (w) => onWarning(rebaseWarningPaths(w, keptIndex));
898
868
  // Generic READ guardrail (0.8.1): a domain that no longer parses degrades to its schema default + a warning
899
869
  // — but ONLY where DOMAIN_READ_FALLBACK says the default is safe; gate-bearing domains keep throwing (决策
900
870
  // 表与逐域理由见 DOMAIN_READ_FALLBACK). The success path is UNCHANGED (same parseDomain, same bytes).
901
- //
902
- // `emit` = 这一腿的告警出口(默认 = 本函数的 `onWarning`)。**刻意不收「外层压缩了多少」这种入参**:
903
- // parseOr 不必知道自己上面还有没有一层,它只管把告警交给给它的那个出口 —— 换算是出口的事。
904
- const parseOr = (domain, raw, emit = onWarning) => {
871
+ const parseOr = (domain, raw) => {
905
872
  try {
906
- return parseDomainLoud(domain, raw, emit);
873
+ return parseDomainLoud(domain, raw, onWarning);
907
874
  }
908
875
  catch (e) {
909
876
  if (DOMAIN_READ_FALLBACK[domain] === "throw")
@@ -916,47 +883,23 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
916
883
  try {
917
884
  const buffered = [];
918
885
  const parsed = parseDomainLoud(domain, salvage.raw, (w) => buffered.push(w));
919
- // 统一律落地:`entry-dropped` 是**本层**(salvage)铸的,用的就是本层入参那份文档的坐标 ⇒
920
- // 不过本层自己的表,直接交给出口(出口会补上更外层的换算);`buffered` 里的是对**本层压缩后**
921
- // 文档的诊断,是从下方上来的 ⇒ 先过本层的表,再交给出口。scan 函数一字不动。
886
+ // 坐标律(S-323b):一条告警用「铸它的那一层所见文档」的坐标表达;哪一层压缩了文档,哪一层就把从它下方上来的告警回写一次。
887
+ // `entry-dropped` 是本层(salvage)铸的,用的就是本层入参那份文档的坐标 ⇒ 原样出门;`buffered` 里的是对本层**压缩后**
888
+ // 文档的诊断 ⇒ 先过本层的位移表。读路径上只有这一个压缩点(11.0.0 起 hosts 的读侧夹紧臂已retired)。
922
889
  for (const d of salvage.dropped)
923
- emit?.(entryDroppedWarning(domain, d));
890
+ onWarning?.(entryDroppedWarning(domain, d));
924
891
  for (const w of buffered)
925
- emit?.(rebaseWarningPaths(w, salvage.keptIndex));
892
+ onWarning?.(rebaseWarningPaths(w, salvage.keptIndex));
926
893
  return parsed;
927
894
  }
928
895
  catch {
929
896
  // 条目之外还有毛病(重名 / 悬空 ref / 域级判据)—— 丢条目救不了它,照常整域降级。
930
897
  }
931
898
  }
932
- emit?.({ domain, kind: "domain-defaulted", error: e });
899
+ onWarning?.({ domain, kind: "domain-defaulted", error: e });
933
900
  return parseDomain(domain, undefined); // schema default — always valid
934
901
  }
935
902
  };
936
- // governance TRUTH: the domain itself; if it was never set, lift the trio from a legacy runtime doc
937
- // (0.5→0.6 upgrade path — a2/local stores keep their gates without a data migration).
938
- const governanceRaw = getRaw("governance");
939
- let governanceSource = governanceRaw;
940
- if (governanceRaw === undefined) {
941
- const lifted = legacyGovernanceFromRuntime(getRaw("runtime"));
942
- // 兼容臂真生效时出声(2.0.0):行为一字不变,但窗要能关就得有人看得见它还开着。
943
- if (lifted !== undefined)
944
- onWarning?.({ domain: "governance", kind: "legacy-governance-lifted", keys: Object.keys(lifted).map((k) => [k]) });
945
- governanceSource = lifted;
946
- }
947
- const governance = parseLoud("governance", governanceSource);
948
- // runtime slot = limit residue ONLY. 0.7.1(BREAKING,service 回执收账):E1 的 governance→runtime
949
- // 六闸双写镜像已撤——service 1.150.0 已切优先读 eff.governance 并回帖确认(撤双写以其确认为门槛,
950
- // ask⑤ 顺序契约兑现)。READ 方向的 legacy lift(governance 域从未设时从旧 runtime 文档抬升三闸,
951
- // 见 legacyGovernanceFromRuntime)保留——那是 0.5→0.6 升级路径,与写出镜像无关。
952
- const runtime = parseLoud("runtime", getRaw("runtime"));
953
- // hosts: the PRECISE grandfather for the 0.8.0 tightening runs BEFORE parse (0.7-era legal data over the
954
- // new bounds reads on, clamped + warned); any residual corruption falls to the generic guardrail above.
955
- const hostsGf = grandfatherHostsRead(getRaw("hosts"));
956
- if (hostsGf.droppedTotal > 0 || hostsGf.truncatedFrom !== undefined) {
957
- const clipped = hostsGf.droppedTotal > hostsGf.droppedNames.length ? { droppedTotal: hostsGf.droppedTotal } : {};
958
- onWarning?.({ domain: "hosts", kind: "hosts-grandfathered", droppedNames: hostsGf.droppedNames, ...clipped, truncatedFrom: hostsGf.truncatedFrom });
959
- }
960
903
  return {
961
904
  version,
962
905
  updatedAt,
@@ -969,11 +912,10 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
969
912
  systems: parseOr("systems", getRaw("systems")),
970
913
  collab: parseOr("collab", getRaw("collab")),
971
914
  workers: parseLoud("workers", getRaw("workers")),
972
- // hosts 是读路径上**唯一**有外层压缩点的腿:`hostsGf.raw` 已被 grandfather 按名剔过条目,所以这一腿的
973
- // 告警出口要裹上它的位移表(统一律:哪一层压缩,哪一层回写从它下方上来的告警)。
974
- hosts: parseOr("hosts", hostsGf.raw, exitOf(hostsGf.keptIndex)),
975
- runtime,
976
- governance,
915
+ hosts: parseOr("hosts", getRaw("hosts")),
916
+ // governance TRUTH = the domain itself (11.0.0: the read-side lift from a 0.5-era runtime document is gone — the
917
+ // runtime tombstone above refuses such a document, and the one-time migration SS-M1 moves its trio here).
918
+ governance: parseLoud("governance", getRaw("governance")),
977
919
  entitlement: parseLoud("entitlement", getRaw("entitlement")),
978
920
  // execution (0.8.0, S3): plain scope-level parse — MIRRORS governance (no overlay/merge invented here).
979
921
  execution: parseLoud("execution", getRaw("execution")),
@@ -989,12 +931,15 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
989
931
  };
990
932
  }
991
933
  /** READ-side tolerant judgment for a GET /effective **wire payload**(the {@link EffectiveWire} contract)at
992
- * its CONSUMPTION boundary — a service pulling from a remote center passes the parsed JSON here instead of
934
+ * its CONSUMPTION boundary — a service pulling from a remote web-admin passes the parsed JSON here instead of
993
935
  * type-asserting it(0.12.0,[2281] 裁B:「两个契约共用一个拼写不是一个被检查的契约」——拼写副本要在
994
936
  * 消费端被真校验)。ONE judgment, two legs: every known domain goes through the SAME `parseDomain` +
995
937
  * {@link DOMAIN_READ_FALLBACK} policy as {@link buildEffective}(catalog 域坏形 → schema default + warning;
996
- * gate 域坏形 → throw,caller 的整包 catch 决定回落),including the governance legacy-lift and the hosts
997
- * read-grandfather — DELEGATED to buildEffective, not re-implemented(单判据双腿消费,§M4)。
938
+ * gate 域坏形 → throw,caller 的整包 catch 决定回落),including the retired-domain tombstone — DELEGATED to
939
+ * buildEffective, not re-implemented(单判据双腿消费,§M4)。A retired domain on the wire (an older web-admin still
940
+ * ships `runtime`) is judged by the same tombstone: carrying facts ⇒ {@link RetiredDomainError}; `{}` (what an older
941
+ * web-admin materialises for an unset domain) carries nothing and is dropped from the result — it never rides through as
942
+ * an open-world key.
998
943
  *
999
944
  * `skills` is the ONE wire divergence: on the wire it is a MANIFEST(content → contentHash,
1000
945
  * {@link SkillsManifest}),so the full skills-domain schema would reject every LEGAL manifest and silently
@@ -1002,9 +947,8 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
1002
947
  * same "default" fallback tier as the skills domain itself.
1003
948
  *
1004
949
  * OPEN-WORLD on unknown top-level keys: they ride through VERBATIM(never validated, never dropped)— a
1005
- * newer center's new domain reaches an older service untouched(§M2 能力面语义:读不到即走老路,不该炸),
1006
- * and the legacy `teams` key(not a domain since 0.7, services still consume it for backward compat)keeps
1007
- * flowing. Throws ONLY on garbage no caller can partially trust: a non-object payload, or a missing/
950
+ * newer web-admin's new domain reaches an older engine untouched(§M2 能力面语义:读不到即走老路,不该炸),
951
+ * and so does any key no domain names(the 0.6-era `teams` key included — not a domain since 0.7, and no engine reads it). Throws ONLY on garbage no caller can partially trust: a non-object payload, or a missing/
1008
952
  * non-finite `version` — that is not "a bad domain", it is not an effective config at all. */
1009
953
  export function readEffectiveWire(raw, onWarning) {
1010
954
  if (raw === null || typeof raw !== "object" || Array.isArray(raw))
@@ -1015,11 +959,12 @@ export function readEffectiveWire(raw, onWarning) {
1015
959
  const updatedAt = typeof r.updatedAt === "string" ? r.updatedAt : "";
1016
960
  // skills redirected to undefined: buildEffective must never see the manifest(it would fail the full-domain
1017
961
  // schema and "default" it with a FALSE domain-defaulted warning);the manifest is judged separately below.
1018
- // prompts likewise redirected(#100):on the wire it is the center-assembled RESOLVED form(worker lane
962
+ // prompts likewise redirected(#100):on the wire it is web-admin-assembled RESOLVED form(worker lane
1019
963
  // strips the bindings storage table — types.ts PromptsConfig 注 [1057]⑤),consumer-validated additive JSON;
1020
964
  // the storage-domain schema would swallow it to {bindings:[]} with ZERO warning(zod non-strict 剥键后
1021
965
  // "成功")— 3.24.0 起 server 侧 promptsGate 恒败的根因。verbatim,by presence(见 return)。
1022
966
  const built = buildEffective(r.version, (d) => (d === "skills" || d === "prompts" ? undefined : r[d]), updatedAt, onWarning);
967
+ // retired 域名不是 open-world 的未知键:墓碑已在 buildEffective 里判过(带事实 ⇒ 抛),剩下的 `{}` 没有可承运的东西。
1023
968
  let skills;
1024
969
  const manifest = SkillsManifestSchema.safeParse(r.skills ?? {});
1025
970
  if (manifest.success) {
@@ -1047,15 +992,15 @@ export function readEffectiveWire(raw, onWarning) {
1047
992
  // 与本文件另两处自有键判据(:548 `rebasePath` 查位移表、:612 `collectDropped` 比键集)同一条律,零特判。
1048
993
  // `Object.fromEntries`,**不是**逐键 `passthrough[k] = v`:后者对 `"__proto__"` 走的是原型 setter
1049
994
  // (改掉对象的原型,而不是加一个键),fromEntries 走 CreateDataProperty —— 同一条「键就是键」的律。
1050
- const passthrough = Object.fromEntries(Object.entries(r).filter(([k]) => !Object.prototype.hasOwnProperty.call(built, k) && k !== "skills"));
995
+ const passthrough = Object.fromEntries(Object.entries(r).filter(([k]) => !Object.prototype.hasOwnProperty.call(built, k) && k !== "skills" && !isRetiredDomainName(k)));
1051
996
  // prompts:剥掉 buildEffective 恒物化的 {bindings:[]} 底座,按 wire 在场性 verbatim 挂回(#100:
1052
- // 缺席进缺席出——center worker-lane 闸关是常态形,物化会让消费端「缺席」分支永不可达)。
997
+ // 缺席进缺席出——web-admin worker-lane 闸关是常态形,物化会让消费端「缺席」分支永不可达)。
1053
998
  const { prompts: _promptsMaterialized, ...builtSansPrompts } = built;
1054
999
  return { ...passthrough, ...builtSansPrompts, skills, ...("prompts" in r ? { prompts: r.prompts } : {}) };
1055
1000
  }
1056
1001
  /** 档位组→引擎单表的唯一解析点(0.9.0)。返回 ACTIVE 档位组的绑定表(剔除未绑档),即 core
1057
- * `RunnerDeps.tiers` 要吃的那张单表——组切换=center 换这张表下发,零新引擎机制。无 active 组/未命中/
1058
- * 组内一档未绑 → `undefined`(引擎档位层恒惰性,与「未配 tiers」逐字同义)。消费方(center /effective
1002
+ * `RunnerDeps.tiers` 要吃的那张单表——组切换=web-admin 换这张表下发,零新引擎机制。无 active 组/未命中/
1003
+ * 组内一档未绑 → `undefined`(引擎档位层恒惰性,与「未配 tiers」逐字同义)。消费方(web-admin /effective
1059
1004
  * 投影、service applyEffective 接线)都走这一个函数,不要各自解析。 */
1060
1005
  export function resolveActiveTiers(models) {
1061
1006
  if (!models.activeTierGroup)