@sema-agent/settings-schema 10.1.1 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/CHANGELOG.md +414 -0
  2. package/README.md +141 -65
  3. package/bin/settings-schema.mjs +13 -6
  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/bundle.d.ts +15 -7
  119. package/dist/bundle.js +28 -27
  120. package/dist/cli.d.ts +3 -1
  121. package/dist/cli.js +132 -81
  122. package/dist/config-d-file.d.ts +14 -0
  123. package/dist/config-d-file.js +67 -0
  124. package/dist/config-fns.d.ts +87 -186
  125. package/dist/config-fns.js +207 -262
  126. package/dist/conformance/contract-adapter.d.ts +9 -2
  127. package/dist/conformance/contract-adapter.js +15 -7
  128. package/dist/conformance/corpus.d.ts +3 -0
  129. package/dist/conformance/corpus.js +5 -0
  130. package/dist/conformance/derive.d.ts +3 -0
  131. package/dist/conformance/index.d.ts +2 -2
  132. package/dist/conformance/index.js +2 -2
  133. package/dist/conformance/run.js +67 -22
  134. package/dist/conformance/types.d.ts +31 -15
  135. package/dist/conformance/types.js +5 -0
  136. package/dist/conformance/warnings.d.ts +1 -1
  137. package/dist/conformance/warnings.js +1 -4
  138. package/dist/cross-domain.d.ts +66 -22
  139. package/dist/cross-domain.js +164 -93
  140. package/dist/cross-session.d.ts +29 -11
  141. package/dist/default-model.d.ts +43 -0
  142. package/dist/default-model.js +34 -0
  143. package/dist/execution-lane-index.d.ts +4 -8
  144. package/dist/execution-lane-index.js +4 -8
  145. package/dist/execution-lanes.d.ts +31 -8
  146. package/dist/execution-lanes.js +23 -5
  147. package/dist/file-edit.js +5 -2
  148. package/dist/file-store.d.ts +7 -6
  149. package/dist/file-store.js +37 -50
  150. package/dist/fleet.d.ts +364 -762
  151. package/dist/fleet.js +277 -224
  152. package/dist/hooks.d.ts +1151 -4737
  153. package/dist/hooks.js +59 -15
  154. package/dist/index.d.ts +10 -4
  155. package/dist/index.js +13 -7
  156. package/dist/integer.d.ts +11 -0
  157. package/dist/integer.js +11 -0
  158. package/dist/issue-path.d.ts +10 -0
  159. package/dist/issue-path.js +10 -0
  160. package/dist/loopback.d.ts +26 -0
  161. package/dist/loopback.js +54 -0
  162. package/dist/migration-receipt.d.ts +42 -0
  163. package/dist/migration-receipt.js +12 -0
  164. package/dist/one-time-migrations.d.ts +56 -0
  165. package/dist/one-time-migrations.js +819 -0
  166. package/dist/prompts-digest.d.ts +1 -1
  167. package/dist/prompts-digest.js +3 -2
  168. package/dist/reader.d.ts +2 -2
  169. package/dist/remote-exec.d.ts +45 -187
  170. package/dist/remote-exec.js +13 -7
  171. package/dist/resolve-roster.d.ts +18 -3
  172. package/dist/resolve-roster.js +28 -25
  173. package/dist/retired-envs.d.ts +24 -0
  174. package/dist/retired-envs.js +114 -0
  175. package/dist/retired-form.d.ts +31 -0
  176. package/dist/retired-form.js +28 -0
  177. package/dist/safety-merge-spec.d.ts +131 -191
  178. package/dist/safety-merge-spec.js +18 -14
  179. package/dist/scheduler-lifetime.d.ts +5 -0
  180. package/dist/scheduler-lifetime.js +7 -0
  181. package/dist/scheduler-store-migration.d.ts +14 -0
  182. package/dist/scheduler-store-migration.js +14 -0
  183. package/dist/scheduler-store-node.d.ts +24 -9
  184. package/dist/scheduler-store-node.js +25 -15
  185. package/dist/scheduler-store.d.ts +47 -14
  186. package/dist/scheduler-store.js +32 -61
  187. package/dist/secret-refs.d.ts +3 -3
  188. package/dist/secret-refs.js +1 -1
  189. package/dist/secret-scan.d.ts +14 -24
  190. package/dist/secret-scan.js +45 -23
  191. package/dist/sha256-core.d.ts +16 -0
  192. package/dist/sha256-core.js +138 -0
  193. package/dist/sha256.d.ts +6 -1
  194. package/dist/sha256.js +8 -98
  195. package/dist/skills-manifest.js +1 -1
  196. package/dist/types.d.ts +1501 -13536
  197. package/dist/types.js +494 -438
  198. package/dist/url-shape.d.ts +34 -0
  199. package/dist/url-shape.js +61 -0
  200. package/dist/web-admin-credential.d.ts +96 -0
  201. package/dist/{center-credential.js → web-admin-credential.js} +46 -43
  202. package/dist/web-admin-env.d.ts +33 -0
  203. package/dist/web-admin-env.js +33 -0
  204. package/dist/worker-row-verdict.d.ts +16 -0
  205. package/dist/worker-row-verdict.js +31 -0
  206. package/dist/workers-rows.d.ts +34 -0
  207. package/dist/workers-rows.js +42 -0
  208. package/dist/zod-unwrap.d.ts +11 -0
  209. package/dist/zod-unwrap.js +25 -0
  210. package/package.json +17 -9
  211. package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/meta.json +0 -6
  212. package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/meta.json +0 -6
  213. package/conformance/migrate-workers/m01-g5-k8s-env-lifted/expected.json +0 -49
  214. package/conformance/migrate-workers/m01-g5-k8s-env-lifted/input.json +0 -26
  215. package/conformance/migrate-workers/m01-g5-k8s-env-lifted/meta.json +0 -4
  216. package/conformance/migrate-workers/m02-g5-idempotent/expected.json +0 -38
  217. package/conformance/migrate-workers/m02-g5-idempotent/input.json +0 -33
  218. package/conformance/migrate-workers/m02-g5-idempotent/meta.json +0 -5
  219. package/conformance/migrate-workers/m03-g5-refused-no-lane/expected.json +0 -71
  220. package/conformance/migrate-workers/m03-g5-refused-no-lane/input.json +0 -42
  221. package/conformance/migrate-workers/m03-g5-refused-no-lane/meta.json +0 -4
  222. package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/expected.json +0 -52
  223. package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/input.json +0 -30
  224. package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/meta.json +0 -4
  225. package/conformance/migrate-workers/m05-row-verdict-reserved-env/expected.json +0 -82
  226. package/conformance/migrate-workers/m05-row-verdict-reserved-env/input.json +0 -68
  227. package/conformance/migrate-workers/m05-row-verdict-reserved-env/meta.json +0 -4
  228. package/dist/center-credential.d.ts +0 -116
  229. package/dist/issue-text.d.ts +0 -10
  230. package/dist/issue-text.js +0 -8
  231. package/dist/migrate-workers.d.ts +0 -71
  232. package/dist/migrate-workers.js +0 -351
  233. package/dist/migrate.d.ts +0 -255
  234. package/dist/migrate.js +0 -542
  235. /package/conformance/cases/{c46-s811-host-name-url-userinfo-characterization → c46-s811-host-name-url-userinfo-withheld}/input.json +0 -0
package/dist/fleet.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @sema-agent/settings-schema/fleet — the PURE scheduling/fleet contract for the sema-center control plane
2
+ * @sema-agent/settings-schema/fleet — the PURE scheduling/fleet contract for the web-admin control plane
3
3
  * (design: the cc-parity control-plane scheduling design note, 产品拍板 2026-07-04 — "one contract, three orchestrators").
4
4
  *
5
5
  * Seven domains (rolling update / discovery / traffic / failover / LB / quota / token governance) are ONE
@@ -7,7 +7,7 @@
7
7
  * but all three read THESE shapes:
8
8
  * - TOC shell (engineLifecycleManager): health readiness + upgrade classification + local blue-green demo
9
9
  * - TOB (user k8s / fixed baseurl): preStop→drain semantics + readiness probe = !draining
10
- * - center (orchestrator reconciler): instance discovery table + /api/dispatch gateway + rolling primitive
10
+ * - web-admin (orchestrator reconciler): instance discovery table + /api/dispatch gateway + rolling primitive
11
11
  *
12
12
  * 🔴 Contract discipline (same rules as scheduler-store):
13
13
  * - ABSTRACT: types + zod wire shapes + PURE decision functions. ZERO IO, ZERO node:* — browser-safe barrel.
@@ -18,19 +18,20 @@
18
18
  * the skeleton does not move).
19
19
  */
20
20
  import { z } from "zod";
21
+ import { INTEGER } from "./integer.js";
21
22
  import { fnv1a64 } from "./hash.js";
22
- // ── The center's fleet endpoints (paths, one spelling) ─────────────────────────────────────────────────────
23
+ // ── Web-admin's fleet endpoints (paths, one spelling) ─────────────────────────────────────────────────────
23
24
  /**
24
- * The center's `/api/fleet/*` endpoints that are addressed from outside the center, relative to its origin. ONE spelling: whoever
25
- * calls one (the engine, the executor) and whoever serves or routes it (the center, the deployment's Service / Ingress) imports it
25
+ * Web-admin's `/api/fleet/*` endpoints that are addressed from outside web-admin, relative to its origin. ONE spelling: whoever
26
+ * calls one (the engine, the executor) and whoever serves or routes it (web-admin, the deployment's Service / Ingress) imports it
26
27
  * here — nobody writes the string. Membership rule: an endpoint is here iff this package types its body, or it belongs to the
27
- * liveness group ({@link FLEET_LIVE_PATHS}); a center-internal route is not.
28
- * - `instances` — `POST` = the heartbeat ({@link InstanceAnnouncement}; its 2xx reply carries the center's
29
- * {@link CenterCapabilities} under {@link HEARTBEAT_REPLY_CAPABILITIES_KEY}), `DELETE` = the goodbye ({@link InstanceDeregistration}).
28
+ * liveness group ({@link FLEET_LIVE_PATHS}); a web-admin-internal route is not.
29
+ * - `instances` — `POST` = the heartbeat ({@link InstanceAnnouncement}; its 2xx reply carries web-admin's
30
+ * {@link WebAdminCapabilities} under {@link HEARTBEAT_REPLY_CAPABILITIES_KEY}), `DELETE` = the goodbye ({@link InstanceDeregistration}).
30
31
  * - `usage` — `POST` a {@link UsageReport}.
31
32
  * - `quotaLeases` — the quota lease ({@link QuotaLease}).
32
33
  * - `servingLease` — `POST` a {@link ServingLeaseRequest}, answered with a {@link ServingLeaseResponse}.
33
- * - `hostPlanner` — `GET` one host's planner record, for the executor (its body is the center's and the executor's contract, not
34
+ * - `hostPlanner` — `GET` one host's planner record, for the executor (its body is web-admin's and the executor's contract, not
34
35
  * this package's; it is here because the deployment routes it as a member of the liveness group).
35
36
  * Notation: a value is a path TEMPLATE — a parameter is a whole segment written `{name}` (`hostPlanner` ends in `/{host}`, filled
36
37
  * with the URL-encoded host id); the other values have none and are the path itself. A prefix router (an Ingress rule) takes the
@@ -44,8 +45,8 @@ export const FLEET_PATHS = {
44
45
  hostPlanner: "/api/fleet/live/host-planner/{host}",
45
46
  };
46
47
  /**
47
- * The LIVENESS group — the fleet endpoints a center answers without its readiness gate and without its config-governance gates
48
- * (they read no config domain and take service credentials only), so that a center whose management face is refused never turns
48
+ * The LIVENESS group — the fleet endpoints a web-admin answers without its readiness gate and without its config-governance gates
49
+ * (they read no config domain and take service credentials only), so that a web-admin whose management face is refused never turns
49
50
  * into a fleet that gets no answers. A deployment routes exactly these past the readiness gate (on Kubernetes: a second Service
50
51
  * with `publishNotReadyAddresses`, and the Ingress rules for them point at it). Membership is THIS list, not a path prefix — the
51
52
  * heartbeat predates the `/api/fleet/live/` prefix; every {@link FLEET_PATHS} entry under that prefix is a member. The
@@ -62,7 +63,7 @@ export const FLEET_LIVE_PATHS = [FLEET_PATHS.instances, FLEET_PATHS.servingLease
62
63
  export const DRAIN_PHASES = ["running", "draining", "drained"];
63
64
  /** Shipped default (server 1.96.0): generous by design — the only unrecoverable loss on restart is the
64
65
  * interactive turn currently streaming, so we wait long rather than cut short. Deployments override via
65
- * DRAIN_GRACE_MS; center overrides per-worker through the worker spec. */
66
+ * DRAIN_GRACE_MS; web-admin overrides per-worker through the worker spec. */
66
67
  export const DEFAULT_DRAIN_GRACE_MS = 600_000;
67
68
  /** Hard floor — a grace below this is treated as this (protects against a fat-fingered 0). */
68
69
  export const DRAIN_GRACE_FLOOR_MS = 10_000;
@@ -78,14 +79,14 @@ export function drainVerdict(s) {
78
79
  return s.nowMs - s.startedMs >= grace ? "exit_forced" : "wait";
79
80
  }
80
81
  /** A wall-clock instant as epoch milliseconds — one quantity, one rule, wherever the fleet contract carries a point in time. */
81
- const EpochMs = z.number().int().nonnegative();
82
- /** A config-center version — a finite number, judged the way `readEffectiveWire` judges one. One quantity, one rule, wherever the
82
+ const EpochMs = INTEGER.nonnegative();
83
+ /** A web-admin config version — a finite number, judged the way `readEffectiveWire` judges one. One quantity, one rule, wherever the
83
84
  * fleet contract carries it: {@link RestartSignal}`.version` and {@link EngineSignal}'s version-ledger keys. */
84
85
  const ConfigVersion = z.number().finite();
85
86
  /**
86
87
  * A generation is a non-negative integer written as its CANONICAL decimal string: `"0"`, `"7"`, `"10"` — no sign, no
87
88
  * leading zero (`"07"` is not a generation), no fraction, no whitespace, ASCII digits only, and a string, never a number.
88
- * One spelling per value, so equality is string equality and the order is numeric ({@link compareGeneration}) — the center's
89
+ * One spelling per value, so equality is string equality and the order is numeric ({@link compareGeneration}) — web-admin's
89
90
  * persisted generation floor and every "is this generation below the floor" judgement rest on that order. The renderer writes
90
91
  * the value into {@link FLEET_GENERATION_ENV}; an env value that is present but not canonical is a misconfiguration to refuse
91
92
  * loudly, not an absent generation (absence is what marks a non-member). Wherever a wire shape here carries a generation
@@ -93,6 +94,11 @@ const ConfigVersion = z.number().finite();
93
94
  * this one predicate.
94
95
  */
95
96
  const CANONICAL_GENERATION = /^(?:0|[1-9][0-9]*)$/;
97
+ /** Drain action id grammar — one identifier shape for the drain request's `actionId` and the `EngineDraining.actionId` it echoes
98
+ * (the engine republishes it on the unauthenticated `/health` face, so it must never carry free text): 1–128 chars, first
99
+ * alphanumeric, then alphanumerics / `.` / `_` / `:` / `-`. Readers and writers share this one constant. */
100
+ export const DRAIN_ACTION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
101
+ const drainActionId = () => z.string().regex(DRAIN_ACTION_ID, "actionId must be an identifier (1–128 chars: alphanumerics, '.', '_', ':', '-'; no spaces or free text)");
96
102
  export function isCanonicalGeneration(value) {
97
103
  return typeof value === "string" && CANONICAL_GENERATION.test(value);
98
104
  }
@@ -113,15 +119,15 @@ const Generation = z.string().refine(isCanonicalGeneration, { message: "a genera
113
119
  /**
114
120
  * The serving lease as the ENGINE holds it — a CLOSED set of four words, owned here (the engine's vocabulary, word for word;
115
121
  * a reader that switches on it exhaustively is told by the compiler when a word is added):
116
- * - `none` — the lease is not in effect: the center has declared no {@link CenterCapabilities}`.servingLease` ({@link readCapabilityReading}
117
- * says `undeclared` — an older center — or `declared` without the bit). The engine behaves exactly as an engine without a lease —
122
+ * - `none` — the lease is not in effect: web-admin has declared no {@link WebAdminCapabilities}`.servingLease` ({@link readCapabilityReading}
123
+ * says `undeclared` — an older web-admin — or `declared` without the bit). The engine behaves exactly as an engine without a lease —
118
124
  * no renewals, no fence. A reply that cannot be read (`unreadable`) is not a reading: it does not move the engine into or out of
119
125
  * `none` — it keeps the state it had.
120
126
  * - `granted` — the engine holds a lease; `leaseUntil` says until when (epoch ms, the engine's own computation from the
121
127
  * granted `ttlMs`, see {@link ServingLeaseResponse}).
122
- * - `expired` — the lease is in effect but no grant is valid right now (the center is unreachable, answered `unknown`, or has
128
+ * - `expired` — the lease is in effect but no grant is valid right now (web-admin is unreachable, answered `unknown`, or has
123
129
  * not granted one yet): the engine keeps serving until its own outage allowance runs out. Not a denial.
124
- * - `denied` — the center denied this instance on positive evidence (the `denied` verdict): sticky; the engine is draining and
130
+ * - `denied` — web-admin denied this instance on positive evidence (the `denied` verdict): sticky; the engine is draining and
125
131
  * stops at its deadline.
126
132
  * Only a fleet member says it: an engine without a generation has no lease and carries no `leaseState` at all (there is no
127
133
  * fifth word for "not a member" — absence is that).
@@ -129,10 +135,10 @@ const Generation = z.string().refine(isCanonicalGeneration, { message: "a genera
129
135
  export const LEASE_STATES = ["none", "granted", "expired", "denied"];
130
136
  export const LeaseState = z.enum(LEASE_STATES);
131
137
  /**
132
- * "This instance must restart to apply config" — the engine's OWN observation (S-741), defined ONCE for both exits that
133
- * carry it: the fleet heartbeat ({@link InstanceAnnouncement}`.restart`, typed) and GET /health (`restart`, read through
134
- * {@link readRestartSignal} — {@link InstanceHealth} keeps carrying it untyped, see there). One mint point, two exits —
135
- * never a second judgement.
138
+ * "This instance must restart to apply config" — the engine's OWN observation (S-741), defined ONCE. It is part of the engine's
139
+ * state, so it rides inside {@link EngineSignal} (`restart`) on both exits — the fleet heartbeat and GET /health — and is read
140
+ * there and only there: `readEngineSignal(body)?.restart`, or `engineSignal?.restart` on a parsed heartbeat / health body. The
141
+ * flat `restart` key the two bodies still carry is the pre-11 dialect (see {@link EngineSignal}); nothing reads it.
136
142
  *
137
143
  * Shape = what the server already ships on /health (`restart: { reasons, version, since }`, plus the optional `withheld`
138
144
  * qualifier below). PRESENCE is the signal:
@@ -140,7 +146,7 @@ export const LeaseState = z.enum(LEASE_STATES);
140
146
  * to presence is redundant, and two spellings of one fact drift).
141
147
  * - `reasons` — which restart-to-apply slices differ from the value baked at boot (the vocabulary is the engine's;
142
148
  * the contract only guarantees non-empty words, at least one).
143
- * - `version` — the config-center version that currently differs from boot (the LATEST seen — it moves forward with
149
+ * - `version` — the web-admin config version that currently differs from boot (the LATEST seen — it moves forward with
144
150
  * later publishes, so a "one restart per version" dedupe bounds restarts per config version, not per reason set).
145
151
  * Judged exactly as `readEffectiveWire` judges a config version (a finite number, `ConfigVersion`) — one quantity, one rule;
146
152
  * {@link EngineSignal}'s two version-ledger keys use the same one.
@@ -177,7 +183,7 @@ function judgeSignalBody(body, pick, schema) {
177
183
  }
178
184
  /**
179
185
  * The reader behind every wire-shape read in this file that has no use for a reason: the engine-owned signals on /health
180
- * ({@link readRestartSignal}, {@link readModelRefusals}, {@link readEngineSignal}) and the request / reply bodies of the
186
+ * ({@link readEngineSignal}, {@link readModelRefusals}) and the request / reply bodies of the
181
187
  * fleet endpoints ({@link readServingLeaseRequest}, {@link readServingLeaseResponse}, {@link readDrainRequest},
182
188
  * {@link readDrainReceipt}). Shape mismatch, a non-object body, even a throwing accessor ⇒ `undefined` — never throws.
183
189
  */
@@ -187,17 +193,9 @@ function readSignalBody(body, pick, schema) {
187
193
  }
188
194
  /** `pick` for a shape whose keys sit flat on the body itself (as opposed to under one key of it). */
189
195
  const wholeBody = (body) => body;
190
- /**
191
- * The /health exit of {@link RestartSignal}: `health.restart` judged against the one shape. Shape mismatch, a missing
192
- * key, a non-object body, even a throwing accessor ⇒ `undefined` — never throws (a probe must not die on one odd body).
193
- * Presence law: only `restart` counts; the top-level `restartRequired` boolean the server also ships is not read.
194
- */
195
- export function readRestartSignal(health) {
196
- return readSignalBody(health, (h) => h.restart, RestartSignal);
197
- }
198
196
  /**
199
197
  * The model-refusal cause codes (S-768) — CLOSED sets, owned HERE, never a hand-copied second list. Adding a code is
200
- * one edit in this file; who goes red at compile time: a reader that switches on the code exhaustively (a center), and
198
+ * one edit in this file; who goes red at compile time: a reader that switches on the code exhaustively (a web-admin), and
201
199
  * a producer only where it holds a table KEYED BY the code (an exhaustive `switch` over the code, or a
202
200
  * `Record<ModelEntryRefusalCode, …>`) — a producer table that merely `satisfies` these codes as values proves a subset,
203
201
  * not equality, and stays green.
@@ -296,44 +294,160 @@ export function readModelRefusals(body) {
296
294
  return undefined;
297
295
  return plane === undefined ? { entries } : { entries, plane };
298
296
  }
299
- /** The `/health` self-description every orchestrator reads (shipped shape, server 1.96.0). Liveness and
300
- * readiness are deliberately SPLIT: `status` stays ok while draining (the process is alive); routability is
301
- * `isReadyForTraffic`. Unknown fields pass through — health is a self-description, not a closed record. */
297
+ // ── The engine's own signal and its one carrier key ─────────────────────────────────────────────────────────
298
+ /**
299
+ * The engine's draining fact — ONE object wherever the engine says "I am draining": inside {@link EngineSignal}`.draining`
300
+ * and as the body of a {@link DrainReceipt}.
301
+ * - `actionId` — the action that put the engine into draining (idempotency key of the drain request).
302
+ * - `since` — epoch ms when draining began; it does not move when the same action repeats.
303
+ * - `deadlineAt` — epoch ms by which the engine stops on its own.
304
+ */
305
+ export const EngineDraining = z.object({
306
+ actionId: drainActionId(),
307
+ since: EpochMs,
308
+ deadlineAt: EpochMs,
309
+ });
310
+ /**
311
+ * Everything the ENGINE itself mints about its own state, as one object. One mint point on the engine; it rides BOTH exits —
312
+ * the `/health` self-description ({@link InstanceHealth}) and the fleet heartbeat ({@link InstanceAnnouncement}) — WHOLE, under
313
+ * the one key {@link ENGINE_SIGNAL_KEY}. Readers read it there and only there: {@link readEngineSignal} for a body,
314
+ * `engineSignal` on a parsed heartbeat / health body; the readiness / routing judges ({@link judgeReadiness},
315
+ * {@link judgeRoutability}) read nothing else.
316
+ *
317
+ * One fact, one place on a body: the engine's STATE is this object; the row's IDENTITY (`instanceId`, `worker`, `address`,
318
+ * `version`, `heartbeatMs`) is the body's top level and is not repeated here. The flat keys of this object's names that the two
319
+ * bodies still carry (`ready`, `draining` as a boolean + `drainingSince`, `restart`, `generation`, `leaseState`, `leaseUntil`)
320
+ * are the pre-11 flat dialect: sent during its contraction window for readers that predate this key, unchanged in shape, read by
321
+ * nothing in this package; their removal is registered (`ONE_TIME_MIGRATIONS` row `SS-W1`).
322
+ *
323
+ * Nested under one key, not flat on the body: the boolean dialect (`draining: boolean` + `drainingSince`, {@link InstanceHealth} /
324
+ * {@link InstanceAnnouncement}) is still sent during its contraction window while `draining` here is an OBJECT — one key cannot hold
325
+ * both, and a flat object `draining` would make every reader still on the boolean refuse a draining engine's heartbeat. Nested, an
326
+ * older reader never sees it (the heartbeat shape strips unknown keys; `/health` is an open record) and a newer one reads only it.
327
+ *
328
+ * - `ready` — always said (never "absent while ready"): the engine can take new work right now.
329
+ * - `draining` — present ⇔ draining ({@link EngineDraining}).
330
+ * - `restart` — the one {@link RestartSignal}, same schema object, no copy.
331
+ * - `generation` — echo of the env the config renderer set on this instance ({@link FLEET_GENERATION_ENV}), a canonical
332
+ * decimal string ({@link isCanonicalGeneration}). Absent ⇔ not a fleet member (a single-machine engine): it takes no lease
333
+ * and is not fenced.
334
+ * - `leaseState` — the serving lease as the engine holds it ({@link LeaseState}: `none` / `granted` / `expired` / `denied`);
335
+ * present ⇔ fleet member (the engine says it on every beat once it has a generation).
336
+ * - `leaseUntil` — epoch ms until which the engine holds its serving lease, as the engine computed it from the last
337
+ * `ttlMs` it was granted ({@link ServingLeaseResponse}); present ⇔ `leaseState` is `granted`.
338
+ * - `configTargetVersion` / `configAppliedVersion` — the web-admin config version the engine was told to converge on, and the
339
+ * one it has applied: numbers, as the engine mints them (finite, the same rule as {@link RestartSignal}`.version`; equal ⇔ converged).
340
+ *
341
+ * Reading: the default strip — only these keys come out. A shape violation in ANY key makes the whole signal `undefined` (it is
342
+ * never "the rest of it"). `undefined` means "no signal this reader can say" — an engine that does not carry the key, or a
343
+ * malformed one — and is NEVER "ready": the judges answer `no-engine-signal`.
344
+ */
345
+ export const EngineSignal = z.object({
346
+ ready: z.boolean(),
347
+ draining: EngineDraining.optional(),
348
+ restart: RestartSignal.optional(),
349
+ generation: Generation.optional(),
350
+ leaseState: LeaseState.optional(),
351
+ leaseUntil: EpochMs.optional(),
352
+ configTargetVersion: ConfigVersion.optional(),
353
+ configAppliedVersion: ConfigVersion.optional(),
354
+ });
355
+ /** The one key {@link EngineSignal} rides under, on the `/health` body and on the heartbeat body alike. Both sides import THIS
356
+ * constant; neither spells the key. */
357
+ export const ENGINE_SIGNAL_KEY = "engineSignal";
358
+ /**
359
+ * The reader of {@link EngineSignal} on a body — and so of every part of it (`readEngineSignal(body)?.restart` is THE restart
360
+ * reading): the value under {@link ENGINE_SIGNAL_KEY}, judged by the same guarded body reader as {@link readModelRefusals}, so the
361
+ * same odd input gets the same answer from both. A missing key, shape mismatch (a missing `ready` included), a non-object body,
362
+ * even a throwing accessor ⇒ `undefined` — never throws. Keys of the signal's names lying flat on the body are NOT read (the flat
363
+ * dialect; the carrier is the one key).
364
+ */
365
+ export function readEngineSignal(body) {
366
+ return readSignalBody(body, (b) => b[ENGINE_SIGNAL_KEY], EngineSignal);
367
+ }
368
+ /** The `/health` self-description every orchestrator reads. Liveness and readiness are deliberately SPLIT: `status` stays ok while
369
+ * draining (the process is alive); readiness is {@link judgeReadiness} over the engine signal the body carries under
370
+ * {@link ENGINE_SIGNAL_KEY}. Unknown fields pass through — health is a self-description, not a closed record: `modelRefusals`
371
+ * rides it untyped and is read with {@link readModelRefusals}; the flat dialect keys other than the boolean pair (`ready`,
372
+ * `restart`, `generation`, `leaseState`, `leaseUntil`) ride it untyped too and are read by nothing. */
302
373
  export const InstanceHealth = z
303
374
  .object({
375
+ /** The instance's identity: the engine's OWN per-process id, minted by the engine every time the process starts (never read
376
+ * from env: replicas of one Deployment share one env, and a restarted container reuses its env, so env cannot tell two
377
+ * processes apart). The same value as the heartbeat's {@link InstanceAnnouncement}`.instanceId`. */
304
378
  instanceId: z.string().min(1),
305
379
  /** build self-description — "did the restart actually land new code" (upgrade classification) */
306
380
  version: z.string().min(1),
307
- /** absent = not draining */
381
+ /** @deprecated Flat engine-state dialect (the boolean draining), sent during its contraction window for readers that predate
382
+ * {@link ENGINE_SIGNAL_KEY}; read nothing off it — draining is {@link EngineSignal}`.draining`. Removal registered
383
+ * (`ONE_TIME_MIGRATIONS` row `SS-W1`). */
308
384
  draining: z.boolean().optional(),
309
- /** epoch ms, present iff draining */
385
+ /** @deprecated Flat engine-state dialect (pairs with the boolean `draining`: epoch ms, present iff draining); same window —
386
+ * read {@link EngineSignal}`.draining.since` instead. */
310
387
  drainingSince: z.number().optional(),
311
- // S-741: the server's `restart` (and its redundant top-level `restartRequired` boolean) ride the passthrough
312
- // UNTYPED on purpose — typing `restart` here would turn today's lenient read into a strict one (a /health body with
313
- // an odd `restart` would stop parsing), which is a MAJOR by this package's release rules. Read it with
314
- // {@link readRestartSignal}. Typing it on this schema is an item for the next major.
315
- // S-768: `modelRefusals` rides the passthrough untyped for the same reason — read it with {@link readModelRefusals}.
388
+ /** The engine's {@link EngineSignal}, whole — the one place its state is read off `/health`. Read exactly as
389
+ * {@link readEngineSignal} reads it: a value that breaks the shape is ABSENT (`.catch`), never a refused body — and an absent
390
+ * signal is `no-engine-signal`, never ready. The key may then be present with the value `undefined`. */
391
+ [ENGINE_SIGNAL_KEY]: EngineSignal.optional().catch(undefined),
316
392
  })
317
393
  .passthrough();
318
- /** Readiness = the routability predicate (k8s readiness probe, gateway health filter, shell seam switch).
319
- * Its input is the boolean-`draining` dialect of {@link InstanceHealth}. An {@link EngineSignal} types `draining` as an
320
- * object, so it does not typecheck here (on purpose: `!== true` would read an object-valued `draining` as "ready").
321
- * The next major retires the boolean pair (`draining` / `drainingSince`) and gives both this predicate and
322
- * {@link isRoutable} `EngineSignal` as their input. */
323
- export function isReadyForTraffic(h) {
324
- return h.draining !== true;
394
+ /** Why an instance is not routable — a CLOSED set, in the order the judges check:
395
+ * - `no-engine-signal` — no {@link EngineSignal} to read (an engine that does not carry it, or a malformed one). Never "ready".
396
+ * - `stale` — silent past its declared heartbeat cadence × {@link STALE_HEARTBEAT_MULTIPLIER} (stored rows only).
397
+ * - `observer-not-ready` — the reconciler's own observation vetoes it (stored rows only; the SECOND source, AND-merged).
398
+ * - `draining` — the engine says it is draining.
399
+ * - `not-ready` — the engine says it is not ready. */
400
+ export const NOT_ROUTABLE_CODES = ["no-engine-signal", "stale", "observer-not-ready", "draining", "not-ready"];
401
+ const notRoutable = (code) => {
402
+ switch (code) {
403
+ case "no-engine-signal":
404
+ return {
405
+ routable: false,
406
+ code,
407
+ why: `no engine signal: the engine predates ${ENGINE_SIGNAL_KEY}, or sent a malformed one — never routed to; its flat ready / draining keys are not read in its place`,
408
+ };
409
+ case "stale":
410
+ return { routable: false, code, why: `silent past ${STALE_HEARTBEAT_MULTIPLIER}× its declared heartbeat cadence` };
411
+ case "observer-not-ready":
412
+ return { routable: false, code, why: "the reconciler observes it not ready" };
413
+ case "draining":
414
+ return { routable: false, code, why: "the engine says it is draining" };
415
+ case "not-ready":
416
+ return { routable: false, code, why: "the engine says it is not ready" };
417
+ default: {
418
+ const unhandled = code;
419
+ throw new Error(`unhandled not-routable code ${String(unhandled)}`);
420
+ }
421
+ }
422
+ };
423
+ /** Readiness — the ONE judge of "may new work go to this engine", from the engine's own signal only (k8s readiness probe,
424
+ * gateway health filter, shell seam switch): ready ⇔ a signal is present, says `ready`, and is not draining. Pass
425
+ * {@link readEngineSignal}`(healthBody)`; `undefined` (no signal) is `no-engine-signal`, never ready. */
426
+ export function judgeReadiness(signal) {
427
+ if (signal === undefined)
428
+ return notRoutable("no-engine-signal");
429
+ if (signal.draining !== undefined)
430
+ return notRoutable("draining");
431
+ if (!signal.ready)
432
+ return notRoutable("not-ready");
433
+ return { routable: true };
434
+ }
435
+ /** {@link judgeReadiness} as a boolean (the same judge, no second reading). */
436
+ export function isReadyForTraffic(signal) {
437
+ return judgeReadiness(signal).routable;
325
438
  }
326
439
  // ── Domain 2: instance discovery (worker announces itself; reconciler observation is the SECOND source) ───
327
- /** Heartbeat cadence the announcement declares (center judges staleness against the DECLARED cadence, so a
440
+ /** Heartbeat cadence the announcement declares (web-admin judges staleness against the DECLARED cadence, so a
328
441
  * slow-heartbeat worker is not falsely reaped). */
329
442
  export const DEFAULT_HEARTBEAT_MS = 30_000;
330
443
  /** An instance is stale once silent for this many missed heartbeats (aligned with the reconciler's own
331
444
  * stale window: 2× cadence, not a fixed wall-clock). */
332
445
  export const STALE_HEARTBEAT_MULTIPLIER = 2;
333
- /** Register/heartbeat upsert (worker → center, D2: worker-active). ONE shape for both — registration is the
446
+ /** Register/heartbeat upsert (worker → web-admin, D2: worker-active). ONE shape for both — registration is the
334
447
  * first heartbeat; every heartbeat re-announces the full row (idempotent upsert, no drift between a
335
- * "register" and a "renew" schema). `draining:true` is the instance SELF-removing from the routable set the
336
- * moment the stop signal lands — faster than any observer.
448
+ * "register" and a "renew" schema). The engine's {@link EngineSignal} rides it whole under {@link ENGINE_SIGNAL_KEY}: a signal
449
+ * saying "draining" is the instance SELF-removing from the routable set the moment the stop signal lands — faster than any
450
+ * observer.
337
451
  * Scope: the body carries NO `scope` — this shape has no such key, and one sent anyway is stripped. The row lands in the scope
338
452
  * the CREDENTIAL names: under a per-worker token, the scope the token binds (a `?scope=` query parameter, when given, must equal
339
453
  * it); under the shared token, the `?scope=` query parameter, absent ⇒ `"global"`. */
@@ -344,14 +458,16 @@ export const InstanceAnnouncement = z.object({
344
458
  /** reachable base URL for the deployment form: k8s pod IP:port / docker net alias / 127.0.0.1:<engine.port> */
345
459
  address: z.string().min(1),
346
460
  version: z.string().min(1),
347
- /** self-declared readiness: model roster landed AND not draining (server ≥7.102.0; older engines: !draining only) */
461
+ /** @deprecated Flat engine-state dialect (self-declared readiness), sent during its contraction window for readers that
462
+ * predate {@link ENGINE_SIGNAL_KEY}; read nothing off it — readiness is {@link EngineSignal}`.ready`. Removal registered
463
+ * (`ONE_TIME_MIGRATIONS` row `SS-W1`). */
348
464
  ready: z.boolean(),
465
+ /** @deprecated Flat engine-state dialect (the boolean draining), same window; read nothing off it — draining is
466
+ * {@link EngineSignal}`.draining`. */
349
467
  draining: z.boolean().default(false),
350
- heartbeatMs: z.number().int().positive().default(DEFAULT_HEARTBEAT_MS),
351
- /** S-741: present ⇔ this instance must restart to apply config — the SAME {@link RestartSignal} /health carries,
352
- * minted by the same getter (self-reported: a worker-scoped credential can only report for its own worker).
353
- * Every heartbeat re-announces the full row, so a later heartbeat WITHOUT it means "no longer required" — the
354
- * stored row ({@link InstanceRegistration}) is replaced, never merged. */
468
+ heartbeatMs: INTEGER.positive().default(DEFAULT_HEARTBEAT_MS),
469
+ /** @deprecated Flat engine-state dialect (the restart signal), same window; read nothing off it — the restart signal is
470
+ * {@link EngineSignal}`.restart`. Its shape is unchanged for the window: a malformed one still refuses the heartbeat, as before. */
355
471
  restart: RestartSignal.optional(),
356
472
  /** S-768: present ⇔ this instance has at least one reportable model refusal — the SAME {@link ModelRefusalsWire} /health
357
473
  * carries, minted by the same getter; read it with {@link readModelRefusals}`(announcement)` (never off this field
@@ -364,20 +480,21 @@ export const InstanceAnnouncement = z.object({
364
480
  * ABSENT (`.catch`) — it never fails the announcement, so it can never make a healthy instance unroutable (unlike
365
481
  * `restart`, whose shape still gates the row). The key may then be present with the value `undefined`. */
366
482
  modelRefusals: ModelRefusalsWire.optional().catch(undefined),
367
- /** Fleet membership and the lease reading, flat on the heartbeat body under the names and values {@link EngineSignal} gives
368
- * them (one mint point on the engine; this shape reuses that shape's schemas, not copies):
369
- * - `generation` — the canonical decimal generation ({@link isCanonicalGeneration}); present ⇔ the instance is a fleet member.
370
- * - `leaseState` — {@link LeaseState}; present ⇔ fleet member.
371
- * - `leaseUntil` — epoch ms until which the held lease runs; present ⇔ `leaseState` is `granted`.
372
- * The presence law between the three is the engine's; this shape does not judge it across keys. Every heartbeat re-announces
373
- * the full row, so a later heartbeat without a key means the engine no longer says it (the stored row is replaced, never merged).
374
- * Failure direction = meaning direction: these are evidence a center keeps, never routing input — a value that breaks its schema
375
- * drops that ONE key to ABSENT (`.catch`) and the heartbeat is still accepted, so a lease word a newer engine adds can never make
376
- * an older center refuse every heartbeat of that engine. A dropped key reads exactly like an unsaid one; the key may then be
377
- * present with the value `undefined`. */
483
+ /** @deprecated Flat engine-state dialect (fleet membership), same window; read nothing off it — it is
484
+ * {@link EngineSignal}`.generation`. Shape unchanged for the window (a value that breaks it drops to absent, as before). */
378
485
  generation: Generation.optional().catch(undefined),
486
+ /** @deprecated Flat engine-state dialect (the lease state), same window; read nothing off it — it is {@link EngineSignal}`.leaseState`. */
379
487
  leaseState: LeaseState.optional().catch(undefined),
488
+ /** @deprecated Flat engine-state dialect (the lease deadline), same window; read nothing off it — it is {@link EngineSignal}`.leaseUntil`. */
380
489
  leaseUntil: EpochMs.optional().catch(undefined),
490
+ /** The engine's {@link EngineSignal}, whole — the one place its state is read off a heartbeat (the routing judge
491
+ * {@link judgeRoutability} reads nothing else). Every heartbeat re-announces the full row, so a later heartbeat whose signal
492
+ * no longer says a part (`restart`, a lease key) means the engine no longer says it — the stored row is replaced, never merged.
493
+ * Read exactly as {@link readEngineSignal} reads it off any body: a value that breaks the shape is ABSENT (`.catch`), never a
494
+ * refused heartbeat — and an absent signal is `no-engine-signal`, never routable, so the failure lands on the routing axis
495
+ * closed, while the rest of the row (restart, refusals, membership evidence) is still kept. The key may then be present with
496
+ * the value `undefined`. */
497
+ [ENGINE_SIGNAL_KEY]: EngineSignal.optional().catch(undefined),
381
498
  });
382
499
  /** Explicit goodbye (stop signal handler) — removes the row immediately instead of waiting out staleness. */
383
500
  export const InstanceDeregistration = z.object({
@@ -385,108 +502,44 @@ export const InstanceDeregistration = z.object({
385
502
  worker: z.string().min(1),
386
503
  reason: z.enum(["draining", "shutdown", "superseded"]).optional(),
387
504
  });
388
- /** The stored discovery-table row = last announcement + center-side bookkeeping. `observedReady` is the
505
+ /** The stored discovery-table row = last announcement + web-admin-side bookkeeping. `observedReady` is the
389
506
  * reconciler's observation (phase Running) — the SECOND source; absent where no reconciler exists (TOC/TOB). */
390
507
  export const InstanceRegistration = InstanceAnnouncement.extend({
391
- registeredMs: z.number().int().nonnegative(),
392
- lastSeenMs: z.number().int().nonnegative(),
508
+ registeredMs: INTEGER.nonnegative(),
509
+ lastSeenMs: INTEGER.nonnegative(),
393
510
  observedReady: z.boolean().optional(),
394
511
  });
395
512
  /** Silent past the declared cadence × multiplier ⇒ stale (auto-removed from the routable set). */
396
513
  export function isInstanceStale(r, nowMs) {
397
514
  return nowMs - r.lastSeenMs > STALE_HEARTBEAT_MULTIPLIER * r.heartbeatMs;
398
515
  }
399
- /** Health fusion: routable ⇔ self-declared ready ∧ not draining ∧ not stale ∧ observer does not veto.
400
- * Two sources merge as an AND — EITHER source's "not ready" removes the instance (fail-closed).
401
- * Its input is the stored heartbeat row (boolean `draining`); it moves to {@link EngineSignal} in the same major as
402
- * {@link isReadyForTraffic}. */
516
+ /** Health fusion — the ONE judge of "may dispatch route to this stored instance": a signal is present ∧ the row is not stale ∧
517
+ * the observer does not veto ∧ the engine's own readiness ({@link judgeReadiness} over the row's {@link EngineSignal}). Two sources
518
+ * merge as an AND — EITHER source's "not ready" removes the instance (fail-closed). The row's boolean `draining` is not read. */
519
+ export function judgeRoutability(r, nowMs) {
520
+ const signal = r[ENGINE_SIGNAL_KEY];
521
+ if (signal === undefined)
522
+ return notRoutable("no-engine-signal");
523
+ if (isInstanceStale(r, nowMs))
524
+ return notRoutable("stale");
525
+ if (r.observedReady === false)
526
+ return notRoutable("observer-not-ready");
527
+ return judgeReadiness(signal);
528
+ }
529
+ /** {@link judgeRoutability} as a boolean (the same judge, no second reading). */
403
530
  export function isRoutable(r, nowMs) {
404
- return r.ready && !r.draining && r.observedReady !== false && !isInstanceStale(r, nowMs);
531
+ return judgeRoutability(r, nowMs).routable;
405
532
  }
406
533
  export function routableInstances(rs, nowMs) {
407
534
  return rs.filter((r) => isRoutable(r, nowMs));
408
535
  }
409
- // ── Domain 2b: the engine's own signal, the serving lease, the drain endpoint ─────────────────────────────
410
- /**
411
- * The engine's draining fact — ONE object wherever the engine says "I am draining": inside {@link EngineSignal}`.draining`
412
- * and as the body of a {@link DrainReceipt}.
413
- * - `actionId` — the action that put the engine into draining (idempotency key of the drain request).
414
- * - `since` — epoch ms when draining began; it does not move when the same action repeats.
415
- * - `deadlineAt` — epoch ms by which the engine stops on its own.
416
- */
417
- export const EngineDraining = z.object({
418
- actionId: z.string().min(1),
419
- since: EpochMs,
420
- deadlineAt: EpochMs,
421
- });
422
- /**
423
- * Everything the ENGINE itself mints about its own state, as one object — the closed form of the engine-owned subset of the
424
- * /health self-description ({@link InstanceHealth} is that self-description: an open record, `.passthrough()`, which also
425
- * carries what the host adds — build, pid, port, store posture). One mint point on the engine; {@link readEngineSignal} reads
426
- * this shape off a body whose keys sit flat on it (no wrapper key).
427
- *
428
- * Where it rides today: on NEITHER exit whole. The heartbeat ({@link InstanceAnnouncement}) and `/health` ({@link InstanceHealth})
429
- * still speak the 8.0 dialect for the keys they share with this shape, in two ways:
430
- * 1. `draining` is a BOOLEAN there, not the {@link EngineDraining} object. The heartbeat always carries it (`false` included);
431
- * `/health` carries `draining: true` only while draining.
432
- * 2. `/health` carries `ready` only while NOT ready (`ready: false`); a ready engine's `/health` body has no `ready` key.
433
- * So `readEngineSignal` over a heartbeat body is always `undefined` (point 1), and over a `/health` body it is `undefined` whenever
434
- * either applies. What does ride both exits flat, under these names and with these values: `restart`, `instanceId`
435
- * and the fleet-membership keys `generation` / `leaseState` / `leaseUntil` — on the heartbeat, read them off the parsed
436
- * {@link InstanceAnnouncement} (which types all five). The next major retires the boolean pair (`draining` / `drainingSince`),
437
- * has both exits carry this shape whole and moves {@link isReadyForTraffic} / {@link isRoutable} onto it; until then this is
438
- * the target form, and `readEngineSignal` is the reader for a body that already speaks it.
439
- *
440
- * - `ready` — always said (never "absent while ready"): the engine can take new work right now.
441
- * - `draining` — present ⇔ draining ({@link EngineDraining}). NOTE the dialect: {@link InstanceHealth}`.draining` and
442
- * {@link InstanceAnnouncement}`.draining` are BOOLEANS (the 8.0 dialect, kept untouched); here it is an object. A body
443
- * whose `draining` is a boolean is therefore not an `EngineSignal` and reads as `undefined`.
444
- * - `restart` — the one {@link RestartSignal}, same schema object, no copy.
445
- * - `generation` — echo of the env the config renderer set on this instance ({@link FLEET_GENERATION_ENV}), a canonical
446
- * decimal string ({@link isCanonicalGeneration}). Absent ⇔ not a fleet member (a single-machine engine): it takes no lease
447
- * and is not fenced.
448
- * - `instanceId` — the engine's OWN process identity, minted by the engine every time the process starts (never read from
449
- * env: replicas of one Deployment share one env, and a restarted container reuses its env, so env cannot tell two
450
- * processes apart). It is present on a standalone engine too — only `generation` marks fleet membership.
451
- * - `leaseState` — the serving lease as the engine holds it ({@link LeaseState}: `none` / `granted` / `expired` / `denied`);
452
- * present ⇔ fleet member (the engine says it on every beat once it has a generation).
453
- * - `leaseUntil` — epoch ms until which the engine holds its serving lease, as the engine computed it from the last
454
- * `ttlMs` it was granted ({@link ServingLeaseResponse}); present ⇔ `leaseState` is `granted`.
455
- * - `configTargetVersion` / `configAppliedVersion` — the config-center version the engine was told to converge on, and the
456
- * one it has applied: numbers, as the engine mints them (finite, the same rule as {@link RestartSignal}`.version`; equal ⇔ converged).
457
- *
458
- * Reading: `EngineSignal` is parsed with the default strip — a whole /health body goes in, only these keys come out. A
459
- * shape violation in ANY key makes the whole signal `undefined` (it is never "the rest of it"). `undefined` means "no
460
- * signal this reader can say" — an engine that predates the keys, or a malformed body — and is NEVER "ready": a caller on
461
- * the routing axis treats it as not routable unless it deliberately falls back to the boolean dialect of
462
- * {@link InstanceHealth}.
463
- */
464
- export const EngineSignal = z.object({
465
- ready: z.boolean(),
466
- draining: EngineDraining.optional(),
467
- restart: RestartSignal.optional(),
468
- generation: Generation.optional(),
469
- instanceId: z.string().min(1).optional(),
470
- leaseState: LeaseState.optional(),
471
- leaseUntil: EpochMs.optional(),
472
- configTargetVersion: ConfigVersion.optional(),
473
- configAppliedVersion: ConfigVersion.optional(),
474
- });
475
- /**
476
- * The reader of {@link EngineSignal}: the shape's keys, flat on the given body, judged by the same guarded body reader as
477
- * {@link readRestartSignal} and {@link readModelRefusals}, so the same odd input gets the same answer from all three. Shape
478
- * mismatch, a missing `ready`, a non-object body, even a throwing accessor ⇒ `undefined` — never throws. See {@link EngineSignal}
479
- * for what `undefined` means (not "ready") and for why today's heartbeat and `/health` bodies read as `undefined` here.
480
- */
481
- export function readEngineSignal(health) {
482
- return readSignalBody(health, wholeBody, EngineSignal);
483
- }
536
+ // ── Domain 2b: the serving lease, the drain endpoint ─────────────────────────────
484
537
  /**
485
538
  * The env name a fleet member's generation travels under — the config renderer sets it, the engine reads it back and echoes
486
539
  * it as {@link EngineSignal}`.generation`. Both sides import THIS constant; neither spells the name. The value is the
487
540
  * generation this instance belongs to (opaque string); absent from the engine's env ⇔ not a fleet member (the standalone
488
541
  * form). There is deliberately NO env name for the instance id: an instance's identity is minted by the engine per process
489
- * ({@link EngineSignal}`.instanceId`), and env cannot carry it (see there).
542
+ * ({@link InstanceHealth}`.instanceId`), and env cannot carry it (see there).
490
543
  */
491
544
  export const FLEET_GENERATION_ENV = "FLEET_GENERATION";
492
545
  /**
@@ -498,19 +551,19 @@ export const FLEET_GENERATION_ENV = "FLEET_GENERATION";
498
551
  * {@link ServingLeaseRequest} and {@link InstanceAnnouncement}; nothing in the engine's env does); the engine's own identity
499
552
  * scope is the one the renderer gave in this env; that the two are the same value is the renderer's obligation.
500
553
  * The engine never derives it from the token's format (a token is opaque to it, and a format guess would be a second source
501
- * that can disagree with the scope the center binds). Absent from the engine's env ⇒ the default scope, {@link GLOBAL_SCOPE} (`api/scopes`) —
502
- * the same default the center reads where nothing names a scope; this note does not restate the value. Like
554
+ * that can disagree with the scope web-admin binds). Absent from the engine's env ⇒ the default scope, {@link GLOBAL_SCOPE} (`api/scopes`) —
555
+ * the same default web-admin reads where nothing names a scope; this note does not restate the value. Like
503
556
  * {@link FLEET_GENERATION_ENV} it is a reserved worker env name (`WorkerSpec.env` / `secretEnv` cannot set it).
504
557
  */
505
558
  export const FLEET_SCOPE_ENV = "FLEET_SCOPE";
506
559
  /**
507
560
  * The serving lease — the ONE fence between a superseded generation and the traffic it must stop taking. A fleet-member
508
- * engine renews it with the center; the center answers per the lifecycle object it holds for that instance. Three constants
561
+ * engine renews it with web-admin; web-admin answers per the lifecycle object it holds for that instance. Three constants
509
562
  * fix the clock, all in ms:
510
563
  * - {@link SERVING_LEASE_TTL_DEFAULT_MS} — the lease length when the operator sets none (10 min).
511
564
  * - {@link SERVING_LEASE_TTL_FLOOR_MS} — the shortest lease there is: a configured TTL below it is treated as it (1 min),
512
565
  * the same way {@link DRAIN_GRACE_FLOOR_MS} protects a fat-fingered grace.
513
- * - {@link SERVING_LEASE_DRIFT_MARGIN_MS} — the center's clock-skew allowance: it adds this to a lease's recorded expiry
566
+ * - {@link SERVING_LEASE_DRIFT_MARGIN_MS} — web-admin's clock-skew allowance: it adds this to a lease's recorded expiry
514
567
  * before it counts that lease as over. It is DERIVED from {@link DEFAULT_HEARTBEAT_MS} (one cadence, one number), never a
515
568
  * second constant that could drift from it.
516
569
  */
@@ -522,7 +575,7 @@ export const SERVING_LEASE_DRIFT_MARGIN_MS = DEFAULT_HEARTBEAT_MS;
522
575
  * {@link SERVING_LEASE_TTL_DEFAULT_MS}; a number below {@link SERVING_LEASE_TTL_FLOOR_MS} (zero and negatives included) ⇒ the
523
576
  * floor; otherwise the number, truncated to whole ms, with no ceiling. It is for the ISSUING side's configuration. A reply's
524
577
  * `ttlMs` is used exactly as granted and is never clamped upward — an engine that stretched a short grant to the floor would
525
- * hold the lease past what the center recorded, which is the one thing the fence exists to prevent.
578
+ * hold the lease past what web-admin recorded, which is the one thing the fence exists to prevent.
526
579
  */
527
580
  export function clampServingLeaseTtlMs(configuredMs) {
528
581
  if (typeof configuredMs !== "number" || !Number.isFinite(configuredMs))
@@ -530,13 +583,13 @@ export function clampServingLeaseTtlMs(configuredMs) {
530
583
  return Math.max(SERVING_LEASE_TTL_FLOOR_MS, Math.floor(configuredMs));
531
584
  }
532
585
  /**
533
- * Engine → center: "may this instance keep serving?" The identity triple names the instance exactly — a lease is never
586
+ * Engine → web-admin: "may this instance keep serving?" The identity triple names the instance exactly — a lease is never
534
587
  * asked for by worker name alone. `generation` is the env echo ({@link FLEET_GENERATION_ENV}); `instanceId` is the engine's
535
- * own per-process identity ({@link EngineSignal}`.instanceId`), so a restarted process is a NEW instance to the center.
588
+ * own per-process identity ({@link InstanceHealth}`.instanceId`), so a restarted process is a NEW instance to web-admin.
536
589
  * `scope` — the tenant scope the lease is asked in. A renewal is taken on a PER-WORKER token only — the credential binds
537
590
  * (scope, worker) — so the credential decides the scope and this field can only agree: absent = the scope the token binds; present
538
591
  * ⇒ it must equal that scope (a mismatch is refused, never rewritten either way). The shared token may not renew a lease at all
539
- * (the center answers 403). Only fleet members ask: an engine without a generation (the standalone form) has no lease and sends
592
+ * (web-admin answers 403). Only fleet members ask: an engine without a generation (the standalone form) has no lease and sends
540
593
  * nothing.
541
594
  */
542
595
  export const ServingLeaseRequest = z.object({
@@ -548,32 +601,32 @@ export const ServingLeaseRequest = z.object({
548
601
  /**
549
602
  * Why a lease is refused — a CLOSED set of exactly two words, owned here:
550
603
  * - `superseded` — a newer generation of this worker has taken over; this instance is the old one.
551
- * - `retired` — the center has retired this instance (or the worker it belongs to).
604
+ * - `retired` — web-admin has retired this instance (or the worker it belongs to).
552
605
  * Only {@link ServingLeaseResponse}'s `denied` arm carries a reason; `unknown` has none, by shape.
553
606
  */
554
607
  export const SERVING_LEASE_DENIED_REASONS = ["superseded", "retired"];
555
608
  export const ServingLeaseDeniedReason = z.enum(SERVING_LEASE_DENIED_REASONS);
556
- const ServingLeaseGranted = z.object({ verdict: z.literal("granted"), ttlMs: z.number().int().positive() });
609
+ const ServingLeaseGranted = z.object({ verdict: z.literal("granted"), ttlMs: INTEGER.positive() });
557
610
  const ServingLeaseDenied = z.object({ verdict: z.literal("denied"), reason: ServingLeaseDeniedReason });
558
611
  const ServingLeaseUnknown = z.object({ verdict: z.literal("unknown") });
559
612
  /**
560
- * Center → engine: the verdict, a CLOSED set of three words ({@link SERVING_LEASE_VERDICTS}) — the arms of one
613
+ * Web-admin → engine: the verdict, a CLOSED set of three words ({@link SERVING_LEASE_VERDICTS}) — the arms of one
561
614
  * discriminated union, so a reader that switches on `verdict` is told by the compiler when a word is added.
562
615
  * - `granted` — keep serving; `ttlMs` is how long the lease lasts, a DURATION, never a wall-clock instant: the engine counts
563
- * it from the instant it SENT the request, on its own monotonic clock (`expiresAt = sendAt + ttlMs`), and the center keeps
616
+ * it from the instant it SENT the request, on its own monotonic clock (`expiresAt = sendAt + ttlMs`), and web-admin keeps
564
617
  * its own expiry from its store time — the two sides never compare clocks, so no clock agreement is needed.
565
618
  * - `denied` — this instance must stop: `reason` is {@link ServingLeaseDeniedReason}. The ONLY arm that obliges an engine to
566
- * stop serving, and it is issued on POSITIVE evidence only: the center keeps, per (scope, worker), a persisted generation
619
+ * stop serving, and it is issued on POSITIVE evidence only: web-admin keeps, per (scope, worker), a persisted generation
567
620
  * floor that is never garbage-collected, and a request whose generation lies below it is `superseded`; `retired` likewise
568
- * rests on a record the center holds. A center that merely holds no such record does not deny — it answers `unknown`.
569
- * - `unknown` — the center holds no lifecycle object for this instance right now (restoring its store, migrating scopes, not
621
+ * rests on a record web-admin holds. A web-admin that merely holds no such record does not deny — it answers `unknown`.
622
+ * - `unknown` — web-admin holds no lifecycle object for this instance right now (restoring its store, migrating scopes, not
570
623
  * yet claimed). It says NOTHING about whether the instance should serve, so it is not a kind of `denied` and absence of the
571
624
  * object NEVER turns into a denial: it carries no reason, and an engine never drains on it. To the engine it is the same
572
- * case as an unreachable center.
573
- * This is the law for what a center SENDS. A reader does not apply it to `reason`: {@link readServingLeaseResponse} judges by
625
+ * case as an unreachable web-admin.
626
+ * This is the law for what a web-admin SENDS. A reader does not apply it to `reason`: {@link readServingLeaseResponse} judges by
574
627
  * the `verdict` key and keeps a `denied` a `denied` even when its reason is outside the set ({@link ServingLeaseReading}). A
575
628
  * reply whose `verdict` itself is outside the three words (or a `granted` with no valid `ttlMs`) is not a verdict at all and
576
- * reads as `undefined` — an unreachable center, never `denied`, never `granted`.
629
+ * reads as `undefined` — an unreachable web-admin, never `denied`, never `granted`.
577
630
  */
578
631
  export const ServingLeaseResponse = z.discriminatedUnion("verdict", [ServingLeaseGranted, ServingLeaseDenied, ServingLeaseUnknown]);
579
632
  /** The reason word a READER puts in place of a `denied` reason it cannot place (outside {@link SERVING_LEASE_DENIED_REASONS},
@@ -584,14 +637,14 @@ const isServingLeaseDeniedReason = (w) => SERVING_LEASE_DENIED_REASONS.includes(
584
637
  * can place is kept, anything else becomes {@link SERVING_LEASE_UNKNOWN_REASON}. The arm is still `denied`. */
585
638
  const ServingLeaseDeniedRead = z.object({
586
639
  verdict: z.literal("denied"),
587
- reason: z.unknown().transform((r) => (isServingLeaseDeniedReason(r) ? r : SERVING_LEASE_UNKNOWN_REASON)),
640
+ reason: z.unknown().optional().transform((r) => (isServingLeaseDeniedReason(r) ? r : SERVING_LEASE_UNKNOWN_REASON)),
588
641
  });
589
642
  /** The read face of {@link ServingLeaseResponse} — same three arms, with the `denied` arm tolerant about `reason`. */
590
643
  const ServingLeaseReadFace = z.discriminatedUnion("verdict", [ServingLeaseGranted, ServingLeaseDeniedRead, ServingLeaseUnknown]);
591
644
  /** The verdict words as a runtime list. `satisfies` bites a word the union does not have; the converse (a union arm missing
592
645
  * from this list) is pinned by a test that reads the union's discriminators. */
593
646
  export const SERVING_LEASE_VERDICTS = ["granted", "denied", "unknown"];
594
- /** Center side: the request body of a serving-lease renewal. Shape mismatch / non-object / throwing accessor ⇒ `undefined`,
647
+ /** Web-admin side: the request body of a serving-lease renewal. Shape mismatch / non-object / throwing accessor ⇒ `undefined`,
595
648
  * never throws (same guarded reader as every other read in this file). */
596
649
  export function readServingLeaseRequest(body) {
597
650
  return readSignalBody(body, wholeBody, ServingLeaseRequest);
@@ -599,53 +652,53 @@ export function readServingLeaseRequest(body) {
599
652
  /** Engine side: the reply to a renewal, judged by its `verdict` key: `granted` needs a valid `ttlMs`, `denied` is `denied`
600
653
  * whatever its `reason` (see {@link ServingLeaseReading}), `unknown` is `unknown`. A non-object, a throwing accessor, a
601
654
  * `verdict` outside the three words or a `granted` without a valid `ttlMs` ⇒ `undefined`, never throws — and `undefined`
602
- * is read like an unreachable center, never like `denied` and never like `granted`. */
655
+ * is read like an unreachable web-admin, never like `denied` and never like `granted`. */
603
656
  export function readServingLeaseResponse(body) {
604
657
  return readSignalBody(body, wholeBody, ServingLeaseReadFace);
605
658
  }
606
659
  /**
607
- * What the CENTER says it can do: capability bits an engine reads before it starts using a center-side endpoint.
608
- * - `servingLease` — the center answers serving-lease renewals ({@link ServingLeaseRequest} / {@link ServingLeaseResponse}).
660
+ * What web-admin says it can do: capability bits an engine reads before it starts using a web-admin-side endpoint.
661
+ * - `servingLease` — web-admin answers serving-lease renewals ({@link ServingLeaseRequest} / {@link ServingLeaseResponse}).
609
662
  *
610
663
  * A bit is `true` or absent — one spelling for "not supported" (there is no `false`). An absent bit — in a declared object, or
611
- * because the center declares nothing at all (an older center) — means the center does not support the lease, and the engine then
612
- * neither renews a lease nor is fenced by one: this is the compatibility gate that lets an executor ship ahead of the center
613
- * without pushing running engines into lease expiry. It is a gate, not a verdict — nothing in this shape says "the center refuses
664
+ * because web-admin declares nothing at all (an older web-admin) — means web-admin does not support the lease, and the engine then
665
+ * neither renews a lease nor is fenced by one: this is the compatibility gate that lets an executor ship ahead of web-admin
666
+ * without pushing running engines into lease expiry. It is a gate, not a verdict — nothing in this shape says "web-admin refuses
614
667
  * leases". Read a heartbeat reply with {@link readCapabilityReading} and start the lease client only on a `declared` reading whose
615
668
  * `capabilities.servingLease === true`.
616
- * Unknown bits are stripped (a newer center's extra bits never break an older reader); a known bit whose value is not `true` breaks
669
+ * Unknown bits are stripped (a newer web-admin's extra bits never break an older reader); a known bit whose value is not `true` breaks
617
670
  * the declaration — and a broken declaration is NOT an absent one: the reply reads as `unreadable`, which is not a reading (the engine
618
671
  * keeps the state it had; see {@link readCapabilityReading}).
619
672
  * It rides in ONE place: the heartbeat's 2xx reply ({@link FLEET_PATHS}`.instances`), under {@link HEARTBEAT_REPLY_CAPABILITIES_KEY}.
620
- * The center answers the bits per CALLER CREDENTIAL: under a per-worker token, by what the center supports; under the shared token
673
+ * Web-admin answers the bits per CALLER CREDENTIAL: under a per-worker token, by what web-admin supports; under the shared token
621
674
  * `servingLease` is always absent (the serving-lease endpoint takes per-worker tokens only, so announcing it there would only push
622
675
  * that caller into the unreachable arm).
623
676
  */
624
- export const CenterCapabilities = z.object({
677
+ export const WebAdminCapabilities = z.object({
625
678
  servingLease: z.literal(true).optional(),
626
679
  });
627
680
  /**
628
- * The key of the heartbeat's 2xx reply body under which the center puts its {@link CenterCapabilities} — the object's only carrier.
629
- * The center writes `{ …, [HEARTBEAT_REPLY_CAPABILITIES_KEY]: caps }`; the engine reads the whole reply with
681
+ * The key of the heartbeat's 2xx reply body under which web-admin puts its {@link WebAdminCapabilities} — the object's only carrier.
682
+ * Web-admin writes `{ …, [HEARTBEAT_REPLY_CAPABILITIES_KEY]: caps }`; the engine reads the whole reply with
630
683
  * `readCapabilityReading(reply)` — the key lookup lives in the reader, one carrier one reader — and only on a 2xx (a failed heartbeat
631
684
  * says nothing about the bits). Nested, not flat: the reply's own keys and the capability bits never share a namespace.
632
- * A reply without the key is an older center — no capability said (`undeclared`). A reply with the key present with a value that
685
+ * A reply without the key is an older web-admin — no capability said (`undeclared`). A reply with the key present with a value that
633
686
  * breaks the shape is NOT "without the key" — it is `unreadable`, and an unreadable reply says nothing: neither declared nor undeclared.
634
687
  */
635
688
  export const HEARTBEAT_REPLY_CAPABILITIES_KEY = "capabilities";
636
689
  /** The reply body as far as the capability carrier is concerned: the one key, optional, nothing else (the reply's other keys are stripped). */
637
- const CapabilitiesCarrier = z.object({ [HEARTBEAT_REPLY_CAPABILITIES_KEY]: CenterCapabilities.optional() });
690
+ const CapabilitiesCarrier = z.object({ [HEARTBEAT_REPLY_CAPABILITIES_KEY]: WebAdminCapabilities.optional() });
638
691
  /**
639
- * Engine side: what one heartbeat reply says about the center's capabilities. The input is the heartbeat's whole 2xx reply body
692
+ * Engine side: what one heartbeat reply says about web-admin's capabilities. The input is the heartbeat's whole 2xx reply body
640
693
  * (the key lookup lives here — one carrier, one reader), the output one of three readings:
641
- * - `declared` — the key ({@link HEARTBEAT_REPLY_CAPABILITIES_KEY}) is present and its value passes {@link CenterCapabilities};
694
+ * - `declared` — the key ({@link HEARTBEAT_REPLY_CAPABILITIES_KEY}) is present and its value passes {@link WebAdminCapabilities};
642
695
  * `capabilities` is the parsed object (unknown bits stripped, an absent bit stays absent).
643
- * - `undeclared` — the reply is an object and the key is absent: an older center, which has said nothing about capabilities.
696
+ * - `undeclared` — the reply is an object and the key is absent: an older web-admin, which has said nothing about capabilities.
644
697
  * (`undefined` as the key's value is what JSON cannot carry; it reads as the key being absent.)
645
698
  * - `unreadable` — the reply is not an object (`why: "not-an-object"`), or the key is present but its value is not a
646
- * {@link CenterCapabilities} (`why: "invalid-capabilities"`; an accessor that throws reads the same way).
699
+ * {@link WebAdminCapabilities} (`why: "invalid-capabilities"`; an accessor that throws reads the same way).
647
700
  * Only `declared` and `undeclared` are readings. `unreadable` is not a reading: on the fence axis a malformed declaration must not
648
- * read as "the center declared nothing", so the engine acts on it as on no reply at all — it keeps the state it last had (before
701
+ * read as "web-admin declared nothing", so the engine acts on it as on no reply at all — it keeps the state it last had (before
649
702
  * any reading: no lease) and leaves its clocks, the lease expiry and the outage allowance, as they were. Start the lease client only
650
703
  * on `declared` with `capabilities.servingLease === true`. Never throws; judged by the same one guarded body judgement as every
651
704
  * other reader here.
@@ -669,8 +722,8 @@ export function readCapabilityReading(reply) {
669
722
  }
670
723
  /**
671
724
  * The drain endpoint's request: "put this instance into draining, by this deadline". One persisted action, one validation,
672
- * whether the center calls directly or an executor retries it. Two lanes, one shape: on the Docker lane (and for a direct
673
- * call from the center) this HTTP request is how an instance is drained; on the Kubernetes lane no per-pod HTTP call is made
725
+ * whether web-admin calls directly or an executor retries it. Two lanes, one shape: on the Docker lane (and for a direct
726
+ * call from web-admin) this HTTP request is how an instance is drained; on the Kubernetes lane no per-pod HTTP call is made
674
727
  * (per-pod addressing and RBAC make it the wrong tool) — an instance is drained by its serving lease being denied
675
728
  * ({@link ServingLeaseResponse}), after which the engine puts itself into draining.
676
729
  * - `actionId` — idempotency key: the same id again echoes the first answer ({@link DrainReceipt}`.state` =
@@ -678,14 +731,14 @@ export function readCapabilityReading(reply) {
678
731
  * - the identity — `scope?` / `worker` / `expectedGeneration?` / `instanceId?` — names WHICH instance the action is for. The
679
732
  * engine compares it field by field against its own, absent against absent: an engine that is not a fleet member has no
680
733
  * generation, so a request with no `expectedGeneration` is the standalone form, not a wildcard; `instanceId`, when given,
681
- * is the engine's own per-process identity ({@link EngineSignal}`.instanceId`). A mismatch is the endpoint's refusal, not
734
+ * is the engine's own per-process identity ({@link InstanceHealth}`.instanceId`). A mismatch is the endpoint's refusal, not
682
735
  * this shape's: this schema only says what a well-formed request looks like.
683
736
  * - `deadlineAt` — epoch ms by which the engine stops on its own.
684
- * - `term` — the claim term the sending executor holds for this action (center-issued, opaque); absent where no executor
737
+ * - `term` — the claim term the sending executor holds for this action (web-admin-issued, opaque); absent where no executor
685
738
  * sits in between (a direct call, a hand-issued action).
686
739
  */
687
740
  export const DrainRequest = z.object({
688
- actionId: z.string().min(1),
741
+ actionId: drainActionId(),
689
742
  scope: z.string().min(1).optional(),
690
743
  worker: z.string().min(1),
691
744
  expectedGeneration: Generation.optional(),
@@ -710,7 +763,7 @@ export function readDrainReceipt(body) {
710
763
  }
711
764
  // ── Domain 3: dispatch policy (traffic + failover + LB — three domains, one gateway) ──────────────────────
712
765
  export const DISPATCH_STRATEGIES = ["round-robin", "least-inflight", "consistent-hash"];
713
- /** Per-worker dispatch policy (center-configured; the gateway is OPTIONAL — D3: TOC/self-maintained TOB go
766
+ /** Per-worker dispatch policy (web-admin-configured; the gateway is OPTIONAL — D3: TOC/self-maintained TOB go
714
767
  * direct, sema-managed fleets route through /api/dispatch). Strategy is an enum on purpose: a new LB
715
768
  * algorithm is a new value, not a new skeleton. */
716
769
  export const DispatchPolicy = z.object({
@@ -720,9 +773,9 @@ export const DispatchPolicy = z.object({
720
773
  * instance dies, the run resumes elsewhere via resumeFromRunId (affinity avoids the gap; resume bounds it). */
721
774
  sessionAffinity: z.boolean().default(true),
722
775
  /** gateway-level concurrency gate (per worker) */
723
- maxInflight: z.number().int().positive().optional(),
776
+ maxInflight: INTEGER.positive().optional(),
724
777
  /** per-actor fairness gate */
725
- perActorMaxInflight: z.number().int().positive().optional(),
778
+ perActorMaxInflight: INTEGER.positive().optional(),
726
779
  /** cost clamp applied to submissions passing the gateway */
727
780
  maxCostUsd: z.number().finite().nonnegative().optional(),
728
781
  });
@@ -765,8 +818,8 @@ export function pickInstance(policy, candidates, opts = {}) {
765
818
  }
766
819
  }
767
820
  // ── Domain 4: quota lease (D4: AP by default — pre-authorized grant + reconciliation, CP is per-tier opt-in) ─
768
- /** A pre-authorized slice of a principal's global budget, granted by center to ONE worker: the worker deducts
769
- * locally (no per-call round-trip), renews when running low, and center reconciles reported consumption
821
+ /** A pre-authorized slice of a principal's global budget, granted by web-admin to ONE worker: the worker deducts
822
+ * locally (no per-call round-trip), renews when running low, and web-admin reconciles reported consumption
770
823
  * against the grant after the fact — the roaming/CDN pattern.
771
824
  *
772
825
  * 0.6.1 双轴 slice(EXPERT-REDESIGN §4-2/§4-6, service 回执"发布即接单"):slice = **{weightedTokens,
@@ -783,17 +836,17 @@ export const QuotaLease = z.object({
783
836
  /** granted dimensions — either or both; a dimension absent = not governed by this lease.
784
837
  * `weightedTokens` = 加权 tokens(配额轴)。 */
785
838
  grant: z.object({
786
- weightedTokens: z.number().int().nonnegative().optional(),
787
- costMicroUsd: z.number().int().nonnegative().optional(),
839
+ weightedTokens: INTEGER.nonnegative().optional(),
840
+ costMicroUsd: INTEGER.nonnegative().optional(),
788
841
  }),
789
842
  consumed: z
790
843
  .object({
791
- weightedTokens: z.number().int().nonnegative().default(0),
792
- costMicroUsd: z.number().int().nonnegative().default(0),
844
+ weightedTokens: INTEGER.nonnegative().default(0),
845
+ costMicroUsd: INTEGER.nonnegative().default(0),
793
846
  })
794
- .default({ weightedTokens: 0, costMicroUsd: 0 }),
795
- issuedMs: z.number().int().nonnegative(),
796
- expiresMs: z.number().int().nonnegative(),
847
+ .prefault({ weightedTokens: 0, costMicroUsd: 0 }),
848
+ issuedMs: INTEGER.nonnegative(),
849
+ expiresMs: INTEGER.nonnegative(),
797
850
  });
798
851
  /** Remaining per granted dimension; null = the dimension is not governed by this lease. Never negative. */
799
852
  export function leaseRemaining(l) {
@@ -822,7 +875,7 @@ export function shouldRenewLease(l, nowMs, opts = {}) {
822
875
  const life = l.expiresMs - l.issuedMs;
823
876
  return life > 0 && (nowMs - l.issuedMs) / life >= f;
824
877
  }
825
- /** Reconciliation (center side): consumption is MONOTONE — the reconciled row takes the max of what center
878
+ /** Reconciliation (web-admin side): consumption is MONOTONE — the reconciled row takes the max of what web-admin
826
879
  * believed and what the worker reported (a lost report can only under-count, never roll back).
827
880
  * 0.7.1:legacy `tokens` report alias removed with the QuotaLease shim(同一收账点,service 回执). */
828
881
  export function reconcileLease(l, reported) {
@@ -834,7 +887,7 @@ export function reconcileLease(l, reported) {
834
887
  },
835
888
  };
836
889
  }
837
- // ── Domain 5: usage report (worker → center — the metering feed the global view aggregates) ───────────────
890
+ // ── Domain 5: usage report (worker → web-admin — the metering feed the global view aggregates) ───────────────
838
891
  /** One principal×model line in a report window(worker 的 fleet 批报腿累计)。0.12.1 语义澄清:早年注写
839
892
  * 「mirrors the worker's modelUsageTracker accumulation」——engine 3.0 计量语义统一后该说法失真:worker 的
840
893
  * per-task modelUsage 面存**未命中输入分量**(与 cacheRead/Write 分列),而**本面 `inputTokens` 是归一化
@@ -843,29 +896,29 @@ export const UsageEntry = z.object({
843
896
  principal: z.string().min(1),
844
897
  model: z.string().min(1),
845
898
  /** 归一化总输入 token(含 cache 读/写),非未命中分量——见类型头注。 */
846
- inputTokens: z.number().int().nonnegative(),
847
- outputTokens: z.number().int().nonnegative(),
899
+ inputTokens: INTEGER.nonnegative(),
900
+ outputTokens: INTEGER.nonnegative(),
848
901
  /** 0.11.0 起 optional(RB-368 生态半场,跨仓记录在案):unpriced 部署(模型无价目)对 cost
849
- * 只有「未知」这个业务态,required 逼 worker 编 0 = center 账面一个**精确的谎**。缺席=未知;
902
+ * 只有「未知」这个业务态,required 逼 worker 编 0 = web-admin 账面一个**精确的谎**。缺席=未知;
850
903
  * 聚合面以 {@link AggregatedPrincipalUsage.costUnknown} 补偿(已知和=显式下界)。写了键 = producer
851
904
  * 声称自己知道(0 就是真 0)。 */
852
- costMicroUsd: z.number().int().nonnegative().optional(),
853
- runs: z.number().int().nonnegative().optional(),
905
+ costMicroUsd: INTEGER.nonnegative().optional(),
906
+ runs: INTEGER.nonnegative().optional(),
854
907
  /** 加权 tokens(配额轴计账值,EXPERT-REDESIGN §4-1):=(input+output)×记账时的 quotaWeight,
855
908
  * **weight-at-burn 持久化**——倍率 publish 只影响此后记账,不回算窗口内历史(余量/恢复时间不跳变)。
856
909
  * 0.6.0 additive optional:旧 worker 不报 → 聚合按 quotaWeight=1 兜底折算。 */
857
- weightedTokens: z.number().int().nonnegative().optional(),
858
- /** 记账时固化的该模型 quotaWeight(审计口径,与 weightedTokens 同时点)——lease 扣减与 registry 聚合两
910
+ weightedTokens: INTEGER.nonnegative().optional(),
911
+ /** 记账时固化的该模型 quotaWeight(审计口径,与 weightedTokens 同时点)——lease 扣减与 web-admin 聚合两
859
912
  * 路径按同一时点折算(同口径,§11-1)。 */
860
913
  quotaWeightAtUse: z.number().finite().nonnegative().optional(),
861
914
  });
862
- /** Periodic batch (worker → center POST). Windows are half-open [start, end) so adjacent batches never
915
+ /** Periodic batch (worker → web-admin POST). Windows are half-open [start, end) so adjacent batches never
863
916
  * double-count a boundary instant. */
864
917
  export const UsageReport = z.object({
865
918
  worker: z.string().min(1),
866
919
  instanceId: z.string().min(1),
867
- windowStartMs: z.number().int().nonnegative(),
868
- windowEndMs: z.number().int().nonnegative(),
920
+ windowStartMs: INTEGER.nonnegative(),
921
+ windowEndMs: INTEGER.nonnegative(),
869
922
  entries: z.array(UsageEntry),
870
923
  });
871
924
  /** 额度清零事件(EXPERT-REDESIGN §4-4,契约随 E1 冻结、E4 消费):申请刷新获批即写一条;5h/周双窗聚合
@@ -886,7 +939,7 @@ export const QuotaResetEvent = z.object({
886
939
  function entryWeightedTokens(e) {
887
940
  return e.weightedTokens ?? Math.round((e.inputTokens + e.outputTokens) * (e.quotaWeightAtUse ?? 1));
888
941
  }
889
- /** Center-side fold: per-principal totals ACROSS workers/instances (the "one user, many workers, one bill"
942
+ /** Web-admin-side fold: per-principal totals ACROSS workers/instances (the "one user, many workers, one bill"
890
943
  * view). Pure — storage/dedup of replayed batches is the caller's concern (key batches by
891
944
  * worker+instanceId+windowStartMs). */
892
945
  export function aggregateUsage(reports) {