@sema-agent/settings-schema 7.0.1 → 8.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 (104) hide show
  1. package/CHANGELOG.md +207 -1
  2. package/README.md +60 -6
  3. package/conformance/VERSION +1 -1
  4. package/conformance/cases/c01-entry-salvage-then-typo/expected.json +5 -0
  5. package/conformance/cases/c02-hosts-clamp-stacked/expected.json +5 -0
  6. package/conformance/cases/c03-literal-dotted-top-key/expected.json +41 -0
  7. package/conformance/cases/c04-all-bad-collection-not-salvaged/expected.json +5 -0
  8. package/conformance/cases/c05-gate-domain-bad-entry-throws/expected.json +5 -0
  9. package/conformance/cases/c06-legacy-governance-lift/expected.json +12 -0
  10. package/conformance/cases/c07-hooks-is-not-a-config-domain/expected.json +5 -0
  11. package/conformance/cases/c08-limits-unknown-carried-64/expected.json +77 -0
  12. package/conformance/cases/c09-limits-unknown-carried-65/expected.json +78 -0
  13. package/conformance/cases/c10-record-key-secret-redacted-twice/expected.json +5 -0
  14. package/conformance/cases/c11-unicode-unknown-keys/expected.json +14 -0
  15. package/conformance/cases/c12-prototype-name-top-keys/expected.json +5 -0
  16. package/conformance/cases/c13-models-field-null/expected.json +5 -0
  17. package/conformance/cases/c14-models-field-empty-array/expected.json +16 -0
  18. package/conformance/cases/c15-models-field-number/expected.json +5 -0
  19. package/conformance/cases/c16-deep-nesting-205/expected.json +14 -0
  20. package/conformance/cases/c17-empty-domain-file/expected.json +10 -0
  21. package/conformance/cases/c18-wire-envelope-not-an-object/expected.json +10 -0
  22. package/conformance/cases/c19-wire-envelope-no-version/expected.json +10 -0
  23. package/conformance/cases/c20-governance-absent-lifts/expected.json +12 -0
  24. package/conformance/cases/c21-governance-empty-object-wins/expected.json +13 -0
  25. package/conformance/cases/c22-governance-null-throws/expected.json +5 -0
  26. package/conformance/cases/c23-mixed-validity-models/expected.json +5 -0
  27. package/conformance/cases/c24-valid-entries-with-unknown-keys/expected.json +29 -0
  28. package/conformance/cases/c25-published-roundtrip-zero-warnings/expected.json +33 -0
  29. package/conformance/cases/c26-hosts-array-1024/expected.json +7183 -0
  30. package/conformance/cases/c27-hosts-array-1025/expected.json +5 -0
  31. package/conformance/cases/c28-large-document-over-1mb/expected.json +14 -0
  32. package/conformance/cases/c29-salvage-then-domain-refine-fails/expected.json +5 -0
  33. package/conformance/cases/c30-version-triple-a-first/expected.json +29 -0
  34. package/conformance/cases/c31-version-triple-b-changed/expected.json +29 -0
  35. package/conformance/cases/c32-version-triple-a-restored/expected.json +29 -0
  36. package/conformance/cases/c33-limits-nested-carried-key/expected.json +17 -0
  37. package/conformance/cases/c34-skills-content-vs-manifest/expected.json +23 -0
  38. package/conformance/cases/c35-portable-domain-file-is-null/expected.json +5 -0
  39. package/conformance/cases/c36-skills-entry-without-content/expected.json +23 -0
  40. package/conformance/cases/c37-skills-field-null/expected.json +5 -0
  41. package/conformance/cases/c38-remote-exec-is-a-non-domain-file/expected.json +5 -0
  42. package/conformance/cases/c39-hidden-file-in-config-d/expected.json +5 -0
  43. package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +5 -0
  44. package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +5 -0
  45. package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +33 -0
  46. package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +5 -0
  47. package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +5 -0
  48. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +5 -0
  49. package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/expected.json +5 -0
  50. package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/expected.json +5 -0
  51. package/conformance/cases/c48-s935-model-headers-transported/expected.json +59 -0
  52. package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/expected.json +5 -0
  53. package/conformance/cases/c50-s784-lane-host-legal/expected.json +44 -0
  54. package/conformance/cases/c51-s784-lane-e2b-legal/expected.json +46 -0
  55. package/conformance/cases/c52-s784-lane-k8s-legal/expected.json +48 -0
  56. package/conformance/cases/c53-s784-lane-ssh-legal/expected.json +48 -0
  57. package/conformance/cases/c54-s784-lane-adb-legal/expected.json +44 -0
  58. package/conformance/cases/c55-s784-lane-local-docker-legal/expected.json +45 -0
  59. package/conformance/cases/c56-s784-lane-device-legal/expected.json +51 -0
  60. package/conformance/cases/c57-s784-g1-enabled-no-lane/expected.json +5 -0
  61. package/conformance/cases/c58-s784-g2-multitenant-host/expected.json +5 -0
  62. package/conformance/cases/c59-s784-g3-k8s-missing-image/expected.json +5 -0
  63. package/conformance/cases/c60-s784-g3-e2b-missing-key/expected.json +5 -0
  64. package/conformance/cases/c61-s784-g3-other-lane-key/expected.json +5 -0
  65. package/conformance/cases/c62-s784-g7a-session-tidb/expected.json +5 -0
  66. package/conformance/cases/c63-s784-host-two-replicas/expected.json +5 -0
  67. package/conformance/cases/c64-s784-multitenant-local-store/expected.json +5 -0
  68. package/conformance/cases/c65-s784-pg-without-db/expected.json +5 -0
  69. package/conformance/cases/c66-s784-memory-backend-mismatch/expected.json +5 -0
  70. package/conformance/cases/c67-s784-retired-field-loud/expected.json +5 -0
  71. package/conformance/cases/c68-s784-device-local-store/expected.json +5 -0
  72. package/conformance/cases/cc01-mcp-toolfaces-non-object/expected.json +54 -0
  73. package/conformance/cases/cc02-mcp-source-key-is-transported/expected.json +27 -0
  74. package/conformance/cases/cc03-model-compat-unknown-subkey/expected.json +33 -0
  75. package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +5 -0
  76. package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +5 -0
  77. package/conformance/cases/cli03-credential-pasted-into-unknown-key/expected.json +22 -0
  78. package/conformance/cases/cli04-env-value-with-newline/expected.json +46 -0
  79. package/conformance/cases/cli05-long-and-prefix-colliding-names/expected.json +53 -0
  80. package/conformance/cases/t01-absent-domain-key-not-null/expected.json +5 -0
  81. package/conformance/cases/t02-entry-salvage-vs-whole-domain-refusal/expected.json +5 -0
  82. package/conformance/cases/t03-prototype-name-key-not-swallowed/expected.json +29 -0
  83. package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +5 -0
  84. package/conformance/cases/z01-every-domain-empty-object/expected.json +64 -0
  85. package/conformance/cases/z02-every-domain-unknown-key/expected.json +66 -0
  86. package/dist/api/auth-bridge.d.ts +2 -2
  87. package/dist/cli.js +58 -26
  88. package/dist/config-fns.js +7 -1
  89. package/dist/conformance/contract-adapter.d.ts +4 -2
  90. package/dist/conformance/contract-adapter.js +21 -7
  91. package/dist/conformance/corpus.js +12 -7
  92. package/dist/conformance/derive.d.ts +26 -0
  93. package/dist/conformance/derive.js +25 -2
  94. package/dist/conformance/index.d.ts +4 -3
  95. package/dist/conformance/index.js +4 -3
  96. package/dist/conformance/run.d.ts +3 -3
  97. package/dist/conformance/run.js +87 -17
  98. package/dist/conformance/types.d.ts +57 -17
  99. package/dist/conformance/types.js +4 -2
  100. package/dist/fleet.d.ts +417 -3
  101. package/dist/fleet.js +287 -12
  102. package/dist/hooks.d.ts +4 -4
  103. package/dist/types.js +3 -1
  104. package/package.json +2 -1
package/dist/fleet.js CHANGED
@@ -40,13 +40,16 @@ export function drainVerdict(s) {
40
40
  const grace = Math.max(DRAIN_GRACE_FLOOR_MS, s.graceMs);
41
41
  return s.nowMs - s.startedMs >= grace ? "exit_forced" : "wait";
42
42
  }
43
+ /** A wall-clock instant as epoch milliseconds — one quantity, one rule, wherever the fleet contract carries a point in time. */
44
+ const EpochMs = z.number().int().nonnegative();
43
45
  /**
44
46
  * "This instance must restart to apply config" — the engine's OWN observation (S-741), defined ONCE for both exits that
45
47
  * carry it: the fleet heartbeat ({@link InstanceAnnouncement}`.restart`, typed) and GET /health (`restart`, read through
46
48
  * {@link readRestartSignal} — {@link InstanceHealth} keeps carrying it untyped, see there). One mint point, two exits —
47
49
  * never a second judgement.
48
50
  *
49
- * Shape = what the server already ships on /health (`restart: { reasons, version, since }`). PRESENCE is the signal:
51
+ * Shape = what the server already ships on /health (`restart: { reasons, version, since }`, plus the optional `withheld`
52
+ * qualifier below). PRESENCE is the signal:
50
53
  * present ⇔ a restart is required, absent ⇔ not; there is deliberately no `required` boolean inside (a boolean next
51
54
  * to presence is redundant, and two spellings of one fact drift).
52
55
  * - `reasons` — which restart-to-apply slices differ from the value baked at boot (the vocabulary is the engine's;
@@ -55,35 +58,45 @@ export function drainVerdict(s) {
55
58
  * later publishes, so a "one restart per version" dedupe bounds restarts per config version, not per reason set).
56
59
  * Judged exactly as `readEffectiveWire` judges a config version (a finite number) — one quantity, one rule.
57
60
  * - `since` — epoch ms when this restart first became required (stable across refreshes while `reasons` holds).
61
+ * - `withheld` — optional: the ENGINE's own verdict that this beat must not be handed to a new generation ("a restart is
62
+ * required, but replacing this instance right now would lose state it holds"). Absent ≡ `false` — an explicit `false`
63
+ * on the wire reads the same as the key being absent, and a reader never mints the key when the producer did not send
64
+ * it. It is a qualifier INSIDE the restart signal, never a signal of its own: it exists only where `restart` does.
58
65
  */
59
66
  export const RestartSignal = z.object({
60
67
  reasons: z.array(z.string().min(1)).min(1),
61
68
  version: z.number().finite(),
62
- since: z.number().int().nonnegative(),
69
+ since: EpochMs,
70
+ withheld: z.boolean().optional(),
63
71
  });
64
72
  /**
65
- * The ONE /health reader behind every engine-owned signal ({@link readRestartSignal}, {@link readModelRefusals}): `health[key]`
66
- * judged against that signal's one shape. Shape mismatch, a missing key, a non-object body, even a throwing accessor ⇒
67
- * `undefined` — never throws (a probe must not die on one odd body).
73
+ * The ONE guarded body reader behind every wire-shape read in this file: the engine-owned signals on /health
74
+ * ({@link readRestartSignal}, {@link readModelRefusals}, {@link readEngineSignal}) and the request / reply bodies of the
75
+ * fleet endpoints ({@link readServingLeaseRequest}, {@link readServingLeaseResponse}, {@link readDrainRequest},
76
+ * {@link readDrainReceipt}). `pick(body)` — one key of the body, or the body itself — is judged against that shape's one
77
+ * schema. Shape mismatch, a non-object body, even a throwing accessor ⇒ `undefined` — never throws (a probe must not die
78
+ * on one odd body). A second hand-written guard anywhere else in this package is the defect, not a convenience.
68
79
  */
69
- function readHealthSignal(health, key, schema) {
80
+ function readSignalBody(body, pick, schema) {
70
81
  try {
71
- if (health === null || typeof health !== "object" || Array.isArray(health))
82
+ if (body === null || typeof body !== "object" || Array.isArray(body))
72
83
  return undefined;
73
- const parsed = schema.safeParse(health[key]);
84
+ const parsed = schema.safeParse(pick(body));
74
85
  return parsed.success ? parsed.data : undefined;
75
86
  }
76
87
  catch {
77
88
  return undefined;
78
89
  }
79
90
  }
91
+ /** `pick` for a shape whose keys sit flat on the body itself (as opposed to under one key of it). */
92
+ const wholeBody = (body) => body;
80
93
  /**
81
94
  * The /health exit of {@link RestartSignal}: `health.restart` judged against the one shape. Shape mismatch, a missing
82
95
  * key, a non-object body, even a throwing accessor ⇒ `undefined` — never throws (a probe must not die on one odd body).
83
96
  * Presence law: only `restart` counts; the top-level `restartRequired` boolean the server also ships is not read.
84
97
  */
85
98
  export function readRestartSignal(health) {
86
- return readHealthSignal(health, "restart", RestartSignal);
99
+ return readSignalBody(health, (h) => h.restart, RestartSignal);
87
100
  }
88
101
  /**
89
102
  * The model-refusal cause codes (S-768) — CLOSED sets, owned HERE, never a hand-copied second list. Adding a code is
@@ -177,7 +190,7 @@ const isModelPlaneRefusalCode = (c) => MODEL_PLANE_REFUSAL_CODES.includes(c);
177
190
  * at all — it answers the key only and never judges the engine's version (see {@link InstanceAnnouncement}`.modelRefusals`).
178
191
  */
179
192
  export function readModelRefusals(body) {
180
- const wire = readHealthSignal(body, "modelRefusals", ModelRefusalsWire);
193
+ const wire = readSignalBody(body, (b) => b.modelRefusals, ModelRefusalsWire);
181
194
  if (wire === undefined)
182
195
  return undefined;
183
196
  const entries = Object.fromEntries(Object.entries(wire.entries).filter((e) => isModelEntryRefusalCode(e[1])));
@@ -205,7 +218,11 @@ export const InstanceHealth = z
205
218
  // S-768: `modelRefusals` rides the passthrough untyped for the same reason — read it with {@link readModelRefusals}.
206
219
  })
207
220
  .passthrough();
208
- /** Readiness = the routability predicate (k8s readiness probe, gateway health filter, shell seam switch). */
221
+ /** Readiness = the routability predicate (k8s readiness probe, gateway health filter, shell seam switch).
222
+ * Its input is the boolean-`draining` dialect of {@link InstanceHealth}. An {@link EngineSignal} types `draining` as an
223
+ * object, so it does not typecheck here (on purpose: `!== true` would read an object-valued `draining` as "ready").
224
+ * The next major retires the boolean pair (`draining` / `drainingSince`) and gives both this predicate and
225
+ * {@link isRoutable} `EngineSignal` as their input. */
209
226
  export function isReadyForTraffic(h) {
210
227
  return h.draining !== true;
211
228
  }
@@ -266,13 +283,271 @@ export function isInstanceStale(r, nowMs) {
266
283
  return nowMs - r.lastSeenMs > STALE_HEARTBEAT_MULTIPLIER * r.heartbeatMs;
267
284
  }
268
285
  /** Health fusion: routable ⇔ self-declared ready ∧ not draining ∧ not stale ∧ observer does not veto.
269
- * Two sources merge as an AND — EITHER source's "not ready" removes the instance (fail-closed). */
286
+ * Two sources merge as an AND — EITHER source's "not ready" removes the instance (fail-closed).
287
+ * Its input is the stored heartbeat row (boolean `draining`); it moves to {@link EngineSignal} in the same major as
288
+ * {@link isReadyForTraffic}. */
270
289
  export function isRoutable(r, nowMs) {
271
290
  return r.ready && !r.draining && r.observedReady !== false && !isInstanceStale(r, nowMs);
272
291
  }
273
292
  export function routableInstances(rs, nowMs) {
274
293
  return rs.filter((r) => isRoutable(r, nowMs));
275
294
  }
295
+ // ── Domain 2b: the engine's own signal, the serving lease, the drain endpoint ─────────────────────────────
296
+ /**
297
+ * The engine's draining fact — ONE object wherever the engine says "I am draining": inside {@link EngineSignal}`.draining`
298
+ * and as the body of a {@link DrainReceipt}.
299
+ * - `actionId` — the action that put the engine into draining (idempotency key of the drain request).
300
+ * - `since` — epoch ms when draining began; it does not move when the same action repeats.
301
+ * - `deadlineAt` — epoch ms by which the engine stops on its own.
302
+ */
303
+ export const EngineDraining = z.object({
304
+ actionId: z.string().min(1),
305
+ since: EpochMs,
306
+ deadlineAt: EpochMs,
307
+ });
308
+ /**
309
+ * A generation is a non-negative integer written as its CANONICAL decimal string: `"0"`, `"7"`, `"10"` — no sign, no
310
+ * leading zero (`"07"` is not a generation), no fraction, no whitespace, ASCII digits only, and a string, never a number.
311
+ * One spelling per value, so equality is string equality and the order is numeric ({@link compareGeneration}) — the center's
312
+ * persisted generation floor and every "is this generation below the floor" judgement rest on that order. The renderer writes
313
+ * the value into {@link FLEET_GENERATION_ENV}; an env value that is present but not canonical is a misconfiguration to refuse
314
+ * loudly, not an absent generation (absence is what marks a non-member). Wherever a wire shape here carries a generation
315
+ * ({@link EngineSignal}`.generation`, {@link ServingLeaseRequest}, {@link DrainRequest}`.expectedGeneration`) it is judged by
316
+ * this one predicate.
317
+ */
318
+ const CANONICAL_GENERATION = /^(?:0|[1-9][0-9]*)$/;
319
+ export function isCanonicalGeneration(value) {
320
+ return typeof value === "string" && CANONICAL_GENERATION.test(value);
321
+ }
322
+ /** Numeric order of two canonical generations: `-1` when `a` is older than `b`, `0` when equal, `1` when newer — `"9"` is
323
+ * older than `"10"`; any number of digits. Throws a `TypeError` on an input that is not canonical (a comparison that quietly
324
+ * returned some number would be a fence decision made on garbage); judge untrusted input with {@link isCanonicalGeneration}
325
+ * first — the wire shapes of this file already do. */
326
+ export function compareGeneration(a, b) {
327
+ if (!isCanonicalGeneration(a) || !isCanonicalGeneration(b)) {
328
+ throw new TypeError(`compareGeneration: a generation is a canonical non-negative decimal string (got ${JSON.stringify(isCanonicalGeneration(a) ? b : a)})`);
329
+ }
330
+ if (a.length !== b.length)
331
+ return a.length < b.length ? -1 : 1; // canonical ⇒ no leading zeros ⇒ more digits is larger
332
+ return a === b ? 0 : a < b ? -1 : 1;
333
+ }
334
+ /** The generation field of every wire shape that carries one — a string judged by {@link isCanonicalGeneration}. */
335
+ const Generation = z.string().refine(isCanonicalGeneration, { message: "a generation is a canonical non-negative decimal string (no sign, no leading zero)" });
336
+ /**
337
+ * Everything the ENGINE itself mints about its own state, as one object — the closed form of the engine-owned subset of the
338
+ * /health self-description ({@link InstanceHealth} is that self-description: an open record, `.passthrough()`, which also
339
+ * carries what the host adds — build, pid, port, store posture). One mint point; the heartbeat and `/health` carry the same
340
+ * keys with the same values, flat (`ready`, `draining`, `restart`, … sit directly on the body, no wrapper key), and
341
+ * {@link readEngineSignal} is the one reader of either.
342
+ *
343
+ * - `ready` — always said (never "absent while ready"): the engine can take new work right now.
344
+ * - `draining` — present ⇔ draining ({@link EngineDraining}). NOTE the dialect: {@link InstanceHealth}`.draining` and
345
+ * {@link InstanceAnnouncement}`.draining` are BOOLEANS (the 8.0 dialect, kept untouched); here it is an object. A body
346
+ * whose `draining` is a boolean is therefore not an `EngineSignal` and reads as `undefined`.
347
+ * - `restart` — the one {@link RestartSignal}, same schema object, no copy.
348
+ * - `generation` — echo of the env the config renderer set on this instance ({@link FLEET_GENERATION_ENV}), a canonical
349
+ * decimal string ({@link isCanonicalGeneration}). Absent ⇔ not a fleet member (a single-machine engine): it takes no lease
350
+ * and is not fenced.
351
+ * - `instanceId` — the engine's OWN process identity, minted by the engine every time the process starts (never read from
352
+ * env: replicas of one Deployment share one env, and a restarted container reuses its env, so env cannot tell two
353
+ * processes apart). It is present on a standalone engine too — only `generation` marks fleet membership.
354
+ * - `leaseUntil` — epoch ms until which the engine holds its serving lease, as the engine computed it from the last
355
+ * `ttlMs` it was granted ({@link ServingLeaseResponse}); absent when it holds none.
356
+ * - `configTargetVersion` / `configAppliedVersion` — the config-center version the engine was told to converge on, and the
357
+ * one it has applied (opaque strings; equal ⇔ converged).
358
+ *
359
+ * Reading: `EngineSignal` is parsed with the default strip — a whole /health body goes in, only these keys come out. A
360
+ * shape violation in ANY key makes the whole signal `undefined` (it is never "the rest of it"). `undefined` means "no
361
+ * signal this reader can say" — an engine that predates the keys, or a malformed body — and is NEVER "ready": a caller on
362
+ * the routing axis treats it as not routable unless it deliberately falls back to the boolean dialect of
363
+ * {@link InstanceHealth}.
364
+ */
365
+ export const EngineSignal = z.object({
366
+ ready: z.boolean(),
367
+ draining: EngineDraining.optional(),
368
+ restart: RestartSignal.optional(),
369
+ generation: Generation.optional(),
370
+ instanceId: z.string().min(1).optional(),
371
+ leaseUntil: EpochMs.optional(),
372
+ configTargetVersion: z.string().min(1).optional(),
373
+ configAppliedVersion: z.string().min(1).optional(),
374
+ });
375
+ /**
376
+ * The reader of {@link EngineSignal}, for either exit (a /health body or a heartbeat body — the keys are flat on both): the
377
+ * same guarded body reader as {@link readRestartSignal} and {@link readModelRefusals}, so the same odd input gets the same
378
+ * answer from all three. Shape mismatch, a missing `ready`, a non-object body, even a throwing accessor ⇒ `undefined` —
379
+ * never throws. See {@link EngineSignal} for what `undefined` means (not "ready").
380
+ */
381
+ export function readEngineSignal(health) {
382
+ return readSignalBody(health, wholeBody, EngineSignal);
383
+ }
384
+ /**
385
+ * The env name a fleet member's generation travels under — the config renderer sets it, the engine reads it back and echoes
386
+ * it as {@link EngineSignal}`.generation`. Both sides import THIS constant; neither spells the name. The value is the
387
+ * generation this instance belongs to (opaque string); absent from the engine's env ⇔ not a fleet member (the standalone
388
+ * form). There is deliberately NO env name for the instance id: an instance's identity is minted by the engine per process
389
+ * ({@link EngineSignal}`.instanceId`), and env cannot carry it (see there).
390
+ */
391
+ export const FLEET_GENERATION_ENV = "FLEET_GENERATION";
392
+ /**
393
+ * The serving lease — the ONE fence between a superseded generation and the traffic it must stop taking. A fleet-member
394
+ * engine renews it with the center; the center answers per the lifecycle object it holds for that instance. Three constants
395
+ * fix the clock, all in ms:
396
+ * - {@link SERVING_LEASE_TTL_DEFAULT_MS} — the lease length when the operator sets none (10 min).
397
+ * - {@link SERVING_LEASE_TTL_FLOOR_MS} — the shortest lease there is: a configured TTL below it is treated as it (1 min),
398
+ * the same way {@link DRAIN_GRACE_FLOOR_MS} protects a fat-fingered grace.
399
+ * - {@link SERVING_LEASE_DRIFT_MARGIN_MS} — the center's clock-skew allowance: it adds this to a lease's recorded expiry
400
+ * before it counts that lease as over. It is DERIVED from {@link DEFAULT_HEARTBEAT_MS} (one cadence, one number), never a
401
+ * second constant that could drift from it.
402
+ */
403
+ export const SERVING_LEASE_TTL_DEFAULT_MS = 600_000;
404
+ export const SERVING_LEASE_TTL_FLOOR_MS = 60_000;
405
+ export const SERVING_LEASE_DRIFT_MARGIN_MS = DEFAULT_HEARTBEAT_MS;
406
+ /**
407
+ * The ONE place a configured lease TTL becomes a usable one: not a number (absent, `NaN`, infinite, another type) ⇒
408
+ * {@link SERVING_LEASE_TTL_DEFAULT_MS}; a number below {@link SERVING_LEASE_TTL_FLOOR_MS} (zero and negatives included) ⇒ the
409
+ * floor; otherwise the number, truncated to whole ms, with no ceiling. It is for the ISSUING side's configuration. A reply's
410
+ * `ttlMs` is used exactly as granted and is never clamped upward — an engine that stretched a short grant to the floor would
411
+ * hold the lease past what the center recorded, which is the one thing the fence exists to prevent.
412
+ */
413
+ export function clampServingLeaseTtlMs(configuredMs) {
414
+ if (typeof configuredMs !== "number" || !Number.isFinite(configuredMs))
415
+ return SERVING_LEASE_TTL_DEFAULT_MS;
416
+ return Math.max(SERVING_LEASE_TTL_FLOOR_MS, Math.floor(configuredMs));
417
+ }
418
+ /**
419
+ * Engine → center: "may this instance keep serving?" The identity triple names the instance exactly — a lease is never
420
+ * asked for by worker name alone. `generation` is the env echo ({@link FLEET_GENERATION_ENV}); `instanceId` is the engine's
421
+ * own per-process identity ({@link EngineSignal}`.instanceId`), so a restarted process is a NEW instance to the center.
422
+ * `scope` is the tenant scope the worker lives in (absent = the single-scope deployment). Only fleet members ask: an engine
423
+ * without a generation (the standalone form) has no lease and sends nothing.
424
+ */
425
+ export const ServingLeaseRequest = z.object({
426
+ worker: z.string().min(1),
427
+ scope: z.string().min(1).optional(),
428
+ generation: Generation,
429
+ instanceId: z.string().min(1),
430
+ });
431
+ /**
432
+ * Why a lease is refused — a CLOSED set of exactly two words, owned here:
433
+ * - `superseded` — a newer generation of this worker has taken over; this instance is the old one.
434
+ * - `retired` — the center has retired this instance (or the worker it belongs to).
435
+ * Only {@link ServingLeaseResponse}'s `denied` arm carries a reason; `unknown` has none, by shape.
436
+ */
437
+ export const SERVING_LEASE_DENIED_REASONS = ["superseded", "retired"];
438
+ export const ServingLeaseDeniedReason = z.enum(SERVING_LEASE_DENIED_REASONS);
439
+ const ServingLeaseGranted = z.object({ verdict: z.literal("granted"), ttlMs: z.number().int().positive() });
440
+ const ServingLeaseDenied = z.object({ verdict: z.literal("denied"), reason: ServingLeaseDeniedReason });
441
+ const ServingLeaseUnknown = z.object({ verdict: z.literal("unknown") });
442
+ /**
443
+ * Center → engine: the verdict, a CLOSED set of three words ({@link SERVING_LEASE_VERDICTS}) — the arms of one
444
+ * discriminated union, so a reader that switches on `verdict` is told by the compiler when a word is added.
445
+ * - `granted` — keep serving; `ttlMs` is how long the lease lasts, a DURATION, never a wall-clock instant: the engine counts
446
+ * it from the instant it SENT the request, on its own monotonic clock (`expiresAt = sendAt + ttlMs`), and the center keeps
447
+ * its own expiry from its store time — the two sides never compare clocks, so no clock agreement is needed.
448
+ * - `denied` — this instance must stop: `reason` is {@link ServingLeaseDeniedReason}. The ONLY arm that obliges an engine to
449
+ * stop serving, and it is issued on POSITIVE evidence only: the center keeps, per (scope, worker), a persisted generation
450
+ * floor that is never garbage-collected, and a request whose generation lies below it is `superseded`; `retired` likewise
451
+ * rests on a record the center holds. A center that merely holds no such record does not deny — it answers `unknown`.
452
+ * - `unknown` — the center holds no lifecycle object for this instance right now (restoring its store, migrating scopes, not
453
+ * yet claimed). It says NOTHING about whether the instance should serve, so it is not a kind of `denied` and absence of the
454
+ * object NEVER turns into a denial: it carries no reason, and an engine never drains on it. To the engine it is the same
455
+ * case as an unreachable center.
456
+ * This is the law for what a center SENDS. A reader does not apply it to `reason`: {@link readServingLeaseResponse} judges by
457
+ * the `verdict` key and keeps a `denied` a `denied` even when its reason is outside the set ({@link ServingLeaseReading}). A
458
+ * reply whose `verdict` itself is outside the three words (or a `granted` with no valid `ttlMs`) is not a verdict at all and
459
+ * reads as `undefined` — an unreachable center, never `denied`, never `granted`.
460
+ */
461
+ export const ServingLeaseResponse = z.discriminatedUnion("verdict", [ServingLeaseGranted, ServingLeaseDenied, ServingLeaseUnknown]);
462
+ /** The reason word a READER puts in place of a `denied` reason it cannot place (outside {@link SERVING_LEASE_DENIED_REASONS},
463
+ * missing, not a string). Read-face only: a producer never sends it, and the strict {@link ServingLeaseResponse} refuses it. */
464
+ export const SERVING_LEASE_UNKNOWN_REASON = "unknown_reason";
465
+ const isServingLeaseDeniedReason = (w) => SERVING_LEASE_DENIED_REASONS.includes(w);
466
+ /** What a reader makes of the `denied` arm: it is judged by the `verdict` key ALONE, and `reason` is diagnostic — a reason it
467
+ * can place is kept, anything else becomes {@link SERVING_LEASE_UNKNOWN_REASON}. The arm is still `denied`. */
468
+ const ServingLeaseDeniedRead = z.object({
469
+ verdict: z.literal("denied"),
470
+ reason: z.unknown().transform((r) => (isServingLeaseDeniedReason(r) ? r : SERVING_LEASE_UNKNOWN_REASON)),
471
+ });
472
+ /** The read face of {@link ServingLeaseResponse} — same three arms, with the `denied` arm tolerant about `reason`. */
473
+ const ServingLeaseReadFace = z.discriminatedUnion("verdict", [ServingLeaseGranted, ServingLeaseDeniedRead, ServingLeaseUnknown]);
474
+ /** The verdict words as a runtime list. `satisfies` bites a word the union does not have; the converse (a union arm missing
475
+ * from this list) is pinned by a test that reads the union's discriminators. */
476
+ export const SERVING_LEASE_VERDICTS = ["granted", "denied", "unknown"];
477
+ /** Center side: the request body of a serving-lease renewal. Shape mismatch / non-object / throwing accessor ⇒ `undefined`,
478
+ * never throws (same guarded reader as every other read in this file). */
479
+ export function readServingLeaseRequest(body) {
480
+ return readSignalBody(body, wholeBody, ServingLeaseRequest);
481
+ }
482
+ /** Engine side: the reply to a renewal, judged by its `verdict` key: `granted` needs a valid `ttlMs`, `denied` is `denied`
483
+ * whatever its `reason` (see {@link ServingLeaseReading}), `unknown` is `unknown`. A non-object, a throwing accessor, a
484
+ * `verdict` outside the three words or a `granted` without a valid `ttlMs` ⇒ `undefined`, never throws — and `undefined`
485
+ * is read like an unreachable center, never like `denied` and never like `granted`. */
486
+ export function readServingLeaseResponse(body) {
487
+ return readSignalBody(body, wholeBody, ServingLeaseReadFace);
488
+ }
489
+ /**
490
+ * What the CENTER says it can do: capability bits an engine reads before it starts using a center-side endpoint.
491
+ * - `servingLease` — the center answers serving-lease renewals ({@link ServingLeaseRequest} / {@link ServingLeaseResponse}).
492
+ *
493
+ * A bit is `true` or absent — one spelling for "not supported" (there is no `false`). Absent (an older center, a body without
494
+ * the key, a body that cannot be read) means the center does not support the lease, and the engine then neither renews a
495
+ * lease nor is fenced by one: this is the compatibility gate that lets an executor ship ahead of the center without pushing
496
+ * running engines into lease expiry. It is a gate, not a verdict — nothing in this shape says "the center refuses leases".
497
+ * A reader asks `readCenterCapabilities(body)?.servingLease === true` and starts the lease client only on that.
498
+ * Unknown bits are stripped (a newer center's extra bits never break an older reader); a known bit whose value is not `true`
499
+ * makes the whole read `undefined`, which reads exactly as absent.
500
+ * This package fixes the shape and the reader, not which center endpoint carries the object.
501
+ */
502
+ export const CenterCapabilities = z.object({
503
+ servingLease: z.literal(true).optional(),
504
+ });
505
+ /** Engine side: the center's capability object. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws
506
+ * (and `undefined` means "no capability said" — the same as every bit absent). */
507
+ export function readCenterCapabilities(body) {
508
+ return readSignalBody(body, wholeBody, CenterCapabilities);
509
+ }
510
+ /**
511
+ * The drain endpoint's request: "put this instance into draining, by this deadline". One persisted action, one validation,
512
+ * whether the center calls directly or an executor retries it. Two lanes, one shape: on the Docker lane (and for a direct
513
+ * call from the center) this HTTP request is how an instance is drained; on the Kubernetes lane no per-pod HTTP call is made
514
+ * (per-pod addressing and RBAC make it the wrong tool) — an instance is drained by its serving lease being denied
515
+ * ({@link ServingLeaseResponse}), after which the engine puts itself into draining.
516
+ * - `actionId` — idempotency key: the same id again echoes the first answer ({@link DrainReceipt}`.state` =
517
+ * `already_draining`) and changes nothing.
518
+ * - the identity — `scope?` / `worker` / `expectedGeneration?` / `instanceId?` — names WHICH instance the action is for. The
519
+ * engine compares it field by field against its own, absent against absent: an engine that is not a fleet member has no
520
+ * generation, so a request with no `expectedGeneration` is the standalone form, not a wildcard; `instanceId`, when given,
521
+ * is the engine's own per-process identity ({@link EngineSignal}`.instanceId`). A mismatch is the endpoint's refusal, not
522
+ * this shape's: this schema only says what a well-formed request looks like.
523
+ * - `deadlineAt` — epoch ms by which the engine stops on its own.
524
+ * - `term` — the claim term the sending executor holds for this action (center-issued, opaque); absent where no executor
525
+ * sits in between (a direct call, a hand-issued action).
526
+ */
527
+ export const DrainRequest = z.object({
528
+ actionId: z.string().min(1),
529
+ scope: z.string().min(1).optional(),
530
+ worker: z.string().min(1),
531
+ expectedGeneration: Generation.optional(),
532
+ instanceId: z.string().min(1).optional(),
533
+ deadlineAt: EpochMs,
534
+ term: z.string().min(1).optional(),
535
+ });
536
+ /** What a drain request leaves the engine in: `draining` — this call put it into draining; `already_draining` — it already was
537
+ * (the same action repeated), and the receipt reports the first call's facts. */
538
+ export const DRAIN_RECEIPT_STATES = ["draining", "already_draining"];
539
+ export const DrainReceiptState = z.enum(DRAIN_RECEIPT_STATES);
540
+ /** The drain endpoint's answer: the engine's draining fact ({@link EngineDraining} — the same object {@link EngineSignal}
541
+ * carries as `draining`, extended, not copied) plus how this call found the engine. */
542
+ export const DrainReceipt = EngineDraining.extend({ state: DrainReceiptState });
543
+ /** Engine side: the request body of the drain endpoint. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws. */
544
+ export function readDrainRequest(body) {
545
+ return readSignalBody(body, wholeBody, DrainRequest);
546
+ }
547
+ /** Caller side: the drain endpoint's answer. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws. */
548
+ export function readDrainReceipt(body) {
549
+ return readSignalBody(body, wholeBody, DrainReceipt);
550
+ }
276
551
  // ── Domain 3: dispatch policy (traffic + failover + LB — three domains, one gateway) ──────────────────────
277
552
  export const DISPATCH_STRATEGIES = ["round-robin", "least-inflight", "consistent-hash"];
278
553
  /** Per-worker dispatch policy (center-configured; the gateway is OPTIONAL — D3: TOC/self-maintained TOB go
package/dist/hooks.d.ts CHANGED
@@ -1575,26 +1575,26 @@ export declare const MessageDisplayHookInput: z.ZodObject<{
1575
1575
  /** Newly completed lines since the prior flush (final flush may end mid-line / be empty). */
1576
1576
  delta: z.ZodString;
1577
1577
  }, "strip", z.ZodTypeAny, {
1578
+ index: number;
1578
1579
  session_id: string;
1579
1580
  transcript_path: string;
1580
1581
  cwd: string;
1581
1582
  hook_event_name: "MessageDisplay";
1582
1583
  turn_id: string;
1583
1584
  message_id: string;
1584
- index: number;
1585
1585
  final: boolean;
1586
1586
  delta: string;
1587
1587
  agent_type?: string | undefined;
1588
1588
  permission_mode?: string | undefined;
1589
1589
  agent_id?: string | undefined;
1590
1590
  }, {
1591
+ index: number;
1591
1592
  session_id: string;
1592
1593
  transcript_path: string;
1593
1594
  cwd: string;
1594
1595
  hook_event_name: "MessageDisplay";
1595
1596
  turn_id: string;
1596
1597
  message_id: string;
1597
- index: number;
1598
1598
  final: boolean;
1599
1599
  delta: string;
1600
1600
  agent_type?: string | undefined;
@@ -3040,26 +3040,26 @@ export declare const HookInput: z.ZodDiscriminatedUnion<"hook_event_name", [z.Zo
3040
3040
  /** Newly completed lines since the prior flush (final flush may end mid-line / be empty). */
3041
3041
  delta: z.ZodString;
3042
3042
  }, "strip", z.ZodTypeAny, {
3043
+ index: number;
3043
3044
  session_id: string;
3044
3045
  transcript_path: string;
3045
3046
  cwd: string;
3046
3047
  hook_event_name: "MessageDisplay";
3047
3048
  turn_id: string;
3048
3049
  message_id: string;
3049
- index: number;
3050
3050
  final: boolean;
3051
3051
  delta: string;
3052
3052
  agent_type?: string | undefined;
3053
3053
  permission_mode?: string | undefined;
3054
3054
  agent_id?: string | undefined;
3055
3055
  }, {
3056
+ index: number;
3056
3057
  session_id: string;
3057
3058
  transcript_path: string;
3058
3059
  cwd: string;
3059
3060
  hook_event_name: "MessageDisplay";
3060
3061
  turn_id: string;
3061
3062
  message_id: string;
3062
- index: number;
3063
3063
  final: boolean;
3064
3064
  delta: string;
3065
3065
  agent_type?: string | undefined;
package/dist/types.js CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { z } from "zod";
19
19
  import { MAX_EXTRA_BODY_DEPTH } from "./hash.js"; // reconcile the extraBody depth cap with the hash cap (R18)
20
- import { DispatchPolicy } from "./fleet.js"; // WorkerSpec.fleet.dispatch (scheduling framework D3)
20
+ import { DispatchPolicy, FLEET_GENERATION_ENV } from "./fleet.js"; // WorkerSpec.fleet.dispatch (scheduling framework D3)
21
21
  import { carriesUserinfo, percentLayers } from "./secret-scan.js"; // package-private normalization + the userinfo judgement (not in the barrel)
22
22
  import { EXECUTION_LANES, EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, SESSION_BACKENDS, STORE_BACKENDS } from "./execution-lanes.js"; // 7.0.0 M-R2: the lane / store vocabulary (one exported table)
23
23
  import { LANE_ENV_OWNER, MIGRATION_SIGNPOST, isMigratableEnvKey, laneEnvSeat, laneRowOf } from "./execution-lane-index.js"; // package-private indexes derived from that table (not in the barrel)
@@ -1569,6 +1569,8 @@ const RESERVED_ENV_FIELD = Object.freeze({
1569
1569
  PG_DATABASE: "runtime.store.db.database", PG_PASSWORD: "secretRefs.dbPasswordEnv",
1570
1570
  // ── runtime.execution(7.0.0):车道词两键 + 车道表里每个名(派生,不手抄)
1571
1571
  REMOTE_EXEC: "runtime.execution.lane",
1572
+ // ── fleet 成员身份:渲染器按该 worker 的代际写它,引擎读回并回声成 EngineSignal.generation —— 没有 WorkerSpec 字段能写它
1573
+ [FLEET_GENERATION_ENV]: "(set by the config renderer from the worker's generation — not configurable)",
1572
1574
  REQUIRE_EXECUTION_LANE: "runtime.execution.lane (the renderer emits REQUIRE_EXECUTION_LANE=true for every declared lane)",
1573
1575
  ...Object.fromEntries([...LANE_ENV_OWNER].map(([name, owner]) => [name, laneEnvSeat(name, owner)])),
1574
1576
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/settings-schema",
3
- "version": "7.0.1",
3
+ "version": "8.1.0",
4
4
  "description": "Sema settings schema — pure config contract (zod domains + effective-config + roster resolution + ref-integrity + remoteExec) shared by sema-registry, sema-server, and the TOC desktop/CLI. One source of truth. (Renamed at 1.0.0 from @sema-agent/registry-core — name and docs only, no behavior change; note the range moves 0.19.0 -> 1.0.0, so a dependency must become ^1.0.0. The old names @sema-agent/registry-core <=0.19.0 and @sema-ai/registry-core remain published, frozen, for migration.)",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -131,6 +131,7 @@
131
131
  "drift:cc": "node scripts/extract-cc-hook-events.mjs",
132
132
  "conformance:bless": "node scripts/gen-conformance.mjs",
133
133
  "conformance:historical": "node scripts/conformance-historical.mjs",
134
+ "api:surface": "node scripts/api-surface.mjs",
134
135
  "prepare": "npm run build",
135
136
  "prepublishOnly": "npm run build && npm run test",
136
137
  "publish:npm": "npm publish --access public --@sema-agent:registry=https://registry.npmjs.org/ --\"//registry.npmjs.org/:_authToken=${NPM_TOKEN}\""