@sema-agent/settings-schema 8.0.0 → 8.2.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.
package/dist/fleet.js CHANGED
@@ -19,6 +19,43 @@
19
19
  */
20
20
  import { z } from "zod";
21
21
  import { fnv1a64 } from "./hash.js";
22
+ // ── The center's fleet endpoints (paths, one spelling) ─────────────────────────────────────────────────────
23
+ /**
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
26
+ * 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}).
30
+ * - `usage` — `POST` a {@link UsageReport}.
31
+ * - `quotaLeases` — the quota lease ({@link QuotaLease}).
32
+ * - `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
+ * this package's; it is here because the deployment routes it as a member of the liveness group).
35
+ * Notation: a value is a path TEMPLATE — a parameter is a whole segment written `{name}` (`hostPlanner` ends in `/{host}`, filled
36
+ * with the URL-encoded host id); the other values have none and are the path itself. A prefix router (an Ingress rule) takes the
37
+ * template up to its first `{`.
38
+ */
39
+ export const FLEET_PATHS = {
40
+ instances: "/api/fleet/instances",
41
+ usage: "/api/fleet/usage",
42
+ quotaLeases: "/api/fleet/leases",
43
+ servingLease: "/api/fleet/live/serving-lease",
44
+ hostPlanner: "/api/fleet/live/host-planner/{host}",
45
+ };
46
+ /**
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
49
+ * into a fleet that gets no answers. A deployment routes exactly these past the readiness gate (on Kubernetes: a second Service
50
+ * with `publishNotReadyAddresses`, and the Ingress rules for them point at it). Membership is THIS list, not a path prefix — the
51
+ * heartbeat predates the `/api/fleet/live/` prefix; every {@link FLEET_PATHS} entry under that prefix is a member. The
52
+ * quota-lease and usage endpoints are not.
53
+ *
54
+ * The same two constants ship as JSON for tooling that cannot import TypeScript (a Helm chart, a smoke script): the subpath
55
+ * `@sema-agent/settings-schema/fleet-paths.json` is `{ "FLEET_PATHS": …, "FLEET_LIVE_PATHS": … }`, written from these values at
56
+ * build time — never edited by hand.
57
+ */
58
+ export const FLEET_LIVE_PATHS = [FLEET_PATHS.instances, FLEET_PATHS.servingLease, FLEET_PATHS.hostPlanner];
22
59
  // ── Domain 1: drain protocol (graceful rolling update — the ground every form stands on) ──────────────────
23
60
  /** Lifecycle of a draining instance. `running`→`draining` on the stop signal; `drained` once in-flight hits
24
61
  * zero (clean) or the grace deadline forces the issue. Exit always follows `drained`. */
@@ -40,13 +77,60 @@ export function drainVerdict(s) {
40
77
  const grace = Math.max(DRAIN_GRACE_FLOOR_MS, s.graceMs);
41
78
  return s.nowMs - s.startedMs >= grace ? "exit_forced" : "wait";
42
79
  }
80
+ /** 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
+ /**
83
+ * A generation is a non-negative integer written as its CANONICAL decimal string: `"0"`, `"7"`, `"10"` — no sign, no
84
+ * leading zero (`"07"` is not a generation), no fraction, no whitespace, ASCII digits only, and a string, never a number.
85
+ * One spelling per value, so equality is string equality and the order is numeric ({@link compareGeneration}) — the center's
86
+ * persisted generation floor and every "is this generation below the floor" judgement rest on that order. The renderer writes
87
+ * the value into {@link FLEET_GENERATION_ENV}; an env value that is present but not canonical is a misconfiguration to refuse
88
+ * loudly, not an absent generation (absence is what marks a non-member). Wherever a wire shape here carries a generation
89
+ * ({@link EngineSignal}`.generation`, {@link ServingLeaseRequest}, {@link DrainRequest}`.expectedGeneration`) it is judged by
90
+ * this one predicate.
91
+ */
92
+ const CANONICAL_GENERATION = /^(?:0|[1-9][0-9]*)$/;
93
+ export function isCanonicalGeneration(value) {
94
+ return typeof value === "string" && CANONICAL_GENERATION.test(value);
95
+ }
96
+ /** Numeric order of two canonical generations: `-1` when `a` is older than `b`, `0` when equal, `1` when newer — `"9"` is
97
+ * older than `"10"`; any number of digits. Throws a `TypeError` on an input that is not canonical (a comparison that quietly
98
+ * returned some number would be a fence decision made on garbage); judge untrusted input with {@link isCanonicalGeneration}
99
+ * first — the wire shapes of this file already do. */
100
+ export function compareGeneration(a, b) {
101
+ if (!isCanonicalGeneration(a) || !isCanonicalGeneration(b)) {
102
+ throw new TypeError(`compareGeneration: a generation is a canonical non-negative decimal string (got ${JSON.stringify(isCanonicalGeneration(a) ? b : a)})`);
103
+ }
104
+ if (a.length !== b.length)
105
+ return a.length < b.length ? -1 : 1; // canonical ⇒ no leading zeros ⇒ more digits is larger
106
+ return a === b ? 0 : a < b ? -1 : 1;
107
+ }
108
+ /** The generation field of every wire shape that carries one — a string judged by {@link isCanonicalGeneration}. */
109
+ const Generation = z.string().refine(isCanonicalGeneration, { message: "a generation is a canonical non-negative decimal string (no sign, no leading zero)" });
110
+ /**
111
+ * The serving lease as the ENGINE holds it — a CLOSED set of four words, owned here (the engine's vocabulary, word for word;
112
+ * a reader that switches on it exhaustively is told by the compiler when a word is added):
113
+ * - `none` — the lease is not in effect: the center has not announced {@link CenterCapabilities}`.servingLease` (an older or
114
+ * degraded center). The engine behaves exactly as an engine without a lease — no renewals, no fence.
115
+ * - `granted` — the engine holds a lease; `leaseUntil` says until when (epoch ms, the engine's own computation from the
116
+ * granted `ttlMs`, see {@link ServingLeaseResponse}).
117
+ * - `expired` — the lease is in effect but no grant is valid right now (the center is unreachable, answered `unknown`, or has
118
+ * not granted one yet): the engine keeps serving until its own outage allowance runs out. Not a denial.
119
+ * - `denied` — the center denied this instance on positive evidence (the `denied` verdict): sticky; the engine is draining and
120
+ * stops at its deadline.
121
+ * Only a fleet member says it: an engine without a generation has no lease and carries no `leaseState` at all (there is no
122
+ * fifth word for "not a member" — absence is that).
123
+ */
124
+ export const LEASE_STATES = ["none", "granted", "expired", "denied"];
125
+ export const LeaseState = z.enum(LEASE_STATES);
43
126
  /**
44
127
  * "This instance must restart to apply config" — the engine's OWN observation (S-741), defined ONCE for both exits that
45
128
  * carry it: the fleet heartbeat ({@link InstanceAnnouncement}`.restart`, typed) and GET /health (`restart`, read through
46
129
  * {@link readRestartSignal} — {@link InstanceHealth} keeps carrying it untyped, see there). One mint point, two exits —
47
130
  * never a second judgement.
48
131
  *
49
- * Shape = what the server already ships on /health (`restart: { reasons, version, since }`). PRESENCE is the signal:
132
+ * Shape = what the server already ships on /health (`restart: { reasons, version, since }`, plus the optional `withheld`
133
+ * qualifier below). PRESENCE is the signal:
50
134
  * present ⇔ a restart is required, absent ⇔ not; there is deliberately no `required` boolean inside (a boolean next
51
135
  * to presence is redundant, and two spellings of one fact drift).
52
136
  * - `reasons` — which restart-to-apply slices differ from the value baked at boot (the vocabulary is the engine's;
@@ -55,35 +139,45 @@ export function drainVerdict(s) {
55
139
  * later publishes, so a "one restart per version" dedupe bounds restarts per config version, not per reason set).
56
140
  * Judged exactly as `readEffectiveWire` judges a config version (a finite number) — one quantity, one rule.
57
141
  * - `since` — epoch ms when this restart first became required (stable across refreshes while `reasons` holds).
142
+ * - `withheld` — optional: the ENGINE's own verdict that this beat must not be handed to a new generation ("a restart is
143
+ * required, but replacing this instance right now would lose state it holds"). Absent ≡ `false` — an explicit `false`
144
+ * on the wire reads the same as the key being absent, and a reader never mints the key when the producer did not send
145
+ * it. It is a qualifier INSIDE the restart signal, never a signal of its own: it exists only where `restart` does.
58
146
  */
59
147
  export const RestartSignal = z.object({
60
148
  reasons: z.array(z.string().min(1)).min(1),
61
149
  version: z.number().finite(),
62
- since: z.number().int().nonnegative(),
150
+ since: EpochMs,
151
+ withheld: z.boolean().optional(),
63
152
  });
64
153
  /**
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).
154
+ * The ONE guarded body reader behind every wire-shape read in this file: the engine-owned signals on /health
155
+ * ({@link readRestartSignal}, {@link readModelRefusals}, {@link readEngineSignal}) and the request / reply bodies of the
156
+ * fleet endpoints ({@link readServingLeaseRequest}, {@link readServingLeaseResponse}, {@link readDrainRequest},
157
+ * {@link readDrainReceipt}). `pick(body)` — one key of the body, or the body itself — is judged against that shape's one
158
+ * schema. Shape mismatch, a non-object body, even a throwing accessor ⇒ `undefined` — never throws (a probe must not die
159
+ * on one odd body). A second hand-written guard anywhere else in this package is the defect, not a convenience.
68
160
  */
69
- function readHealthSignal(health, key, schema) {
161
+ function readSignalBody(body, pick, schema) {
70
162
  try {
71
- if (health === null || typeof health !== "object" || Array.isArray(health))
163
+ if (body === null || typeof body !== "object" || Array.isArray(body))
72
164
  return undefined;
73
- const parsed = schema.safeParse(health[key]);
165
+ const parsed = schema.safeParse(pick(body));
74
166
  return parsed.success ? parsed.data : undefined;
75
167
  }
76
168
  catch {
77
169
  return undefined;
78
170
  }
79
171
  }
172
+ /** `pick` for a shape whose keys sit flat on the body itself (as opposed to under one key of it). */
173
+ const wholeBody = (body) => body;
80
174
  /**
81
175
  * The /health exit of {@link RestartSignal}: `health.restart` judged against the one shape. Shape mismatch, a missing
82
176
  * key, a non-object body, even a throwing accessor ⇒ `undefined` — never throws (a probe must not die on one odd body).
83
177
  * Presence law: only `restart` counts; the top-level `restartRequired` boolean the server also ships is not read.
84
178
  */
85
179
  export function readRestartSignal(health) {
86
- return readHealthSignal(health, "restart", RestartSignal);
180
+ return readSignalBody(health, (h) => h.restart, RestartSignal);
87
181
  }
88
182
  /**
89
183
  * The model-refusal cause codes (S-768) — CLOSED sets, owned HERE, never a hand-copied second list. Adding a code is
@@ -177,7 +271,7 @@ const isModelPlaneRefusalCode = (c) => MODEL_PLANE_REFUSAL_CODES.includes(c);
177
271
  * at all — it answers the key only and never judges the engine's version (see {@link InstanceAnnouncement}`.modelRefusals`).
178
272
  */
179
273
  export function readModelRefusals(body) {
180
- const wire = readHealthSignal(body, "modelRefusals", ModelRefusalsWire);
274
+ const wire = readSignalBody(body, (b) => b.modelRefusals, ModelRefusalsWire);
181
275
  if (wire === undefined)
182
276
  return undefined;
183
277
  const entries = Object.fromEntries(Object.entries(wire.entries).filter((e) => isModelEntryRefusalCode(e[1])));
@@ -205,7 +299,11 @@ export const InstanceHealth = z
205
299
  // S-768: `modelRefusals` rides the passthrough untyped for the same reason — read it with {@link readModelRefusals}.
206
300
  })
207
301
  .passthrough();
208
- /** Readiness = the routability predicate (k8s readiness probe, gateway health filter, shell seam switch). */
302
+ /** Readiness = the routability predicate (k8s readiness probe, gateway health filter, shell seam switch).
303
+ * Its input is the boolean-`draining` dialect of {@link InstanceHealth}. An {@link EngineSignal} types `draining` as an
304
+ * object, so it does not typecheck here (on purpose: `!== true` would read an object-valued `draining` as "ready").
305
+ * The next major retires the boolean pair (`draining` / `drainingSince`) and gives both this predicate and
306
+ * {@link isRoutable} `EngineSignal` as their input. */
209
307
  export function isReadyForTraffic(h) {
210
308
  return h.draining !== true;
211
309
  }
@@ -219,7 +317,10 @@ export const STALE_HEARTBEAT_MULTIPLIER = 2;
219
317
  /** Register/heartbeat upsert (worker → center, D2: worker-active). ONE shape for both — registration is the
220
318
  * first heartbeat; every heartbeat re-announces the full row (idempotent upsert, no drift between a
221
319
  * "register" and a "renew" schema). `draining:true` is the instance SELF-removing from the routable set the
222
- * moment the stop signal lands — faster than any observer. */
320
+ * moment the stop signal lands — faster than any observer.
321
+ * Scope: the body carries NO `scope` — this shape has no such key, and one sent anyway is stripped. The row lands in the scope
322
+ * the CREDENTIAL names: under a per-worker token, the scope the token binds (a `?scope=` query parameter, when given, must equal
323
+ * it); under the shared token, the `?scope=` query parameter, absent ⇒ `"global"`. */
223
324
  export const InstanceAnnouncement = z.object({
224
325
  instanceId: z.string().min(1),
225
326
  /** worker (deployment) this instance belongs to — the dispatch key */
@@ -247,6 +348,20 @@ export const InstanceAnnouncement = z.object({
247
348
  * ABSENT (`.catch`) — it never fails the announcement, so it can never make a healthy instance unroutable (unlike
248
349
  * `restart`, whose shape still gates the row). The key may then be present with the value `undefined`. */
249
350
  modelRefusals: ModelRefusalsWire.optional().catch(undefined),
351
+ /** Fleet membership and the lease reading, flat on the heartbeat body under the names and values {@link EngineSignal} gives
352
+ * them (one mint point on the engine; this shape reuses that shape's schemas, not copies):
353
+ * - `generation` — the canonical decimal generation ({@link isCanonicalGeneration}); present ⇔ the instance is a fleet member.
354
+ * - `leaseState` — {@link LeaseState}; present ⇔ fleet member.
355
+ * - `leaseUntil` — epoch ms until which the held lease runs; present ⇔ `leaseState` is `granted`.
356
+ * The presence law between the three is the engine's; this shape does not judge it across keys. Every heartbeat re-announces
357
+ * the full row, so a later heartbeat without a key means the engine no longer says it (the stored row is replaced, never merged).
358
+ * Failure direction = meaning direction: these are evidence a center keeps, never routing input — a value that breaks its schema
359
+ * drops that ONE key to ABSENT (`.catch`) and the heartbeat is still accepted, so a lease word a newer engine adds can never make
360
+ * an older center refuse every heartbeat of that engine. A dropped key reads exactly like an unsaid one; the key may then be
361
+ * present with the value `undefined`. */
362
+ generation: Generation.optional().catch(undefined),
363
+ leaseState: LeaseState.optional().catch(undefined),
364
+ leaseUntil: EpochMs.optional().catch(undefined),
250
365
  });
251
366
  /** Explicit goodbye (stop signal handler) — removes the row immediately instead of waiting out staleness. */
252
367
  export const InstanceDeregistration = z.object({
@@ -266,13 +381,272 @@ export function isInstanceStale(r, nowMs) {
266
381
  return nowMs - r.lastSeenMs > STALE_HEARTBEAT_MULTIPLIER * r.heartbeatMs;
267
382
  }
268
383
  /** 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). */
384
+ * Two sources merge as an AND — EITHER source's "not ready" removes the instance (fail-closed).
385
+ * Its input is the stored heartbeat row (boolean `draining`); it moves to {@link EngineSignal} in the same major as
386
+ * {@link isReadyForTraffic}. */
270
387
  export function isRoutable(r, nowMs) {
271
388
  return r.ready && !r.draining && r.observedReady !== false && !isInstanceStale(r, nowMs);
272
389
  }
273
390
  export function routableInstances(rs, nowMs) {
274
391
  return rs.filter((r) => isRoutable(r, nowMs));
275
392
  }
393
+ // ── Domain 2b: the engine's own signal, the serving lease, the drain endpoint ─────────────────────────────
394
+ /**
395
+ * The engine's draining fact — ONE object wherever the engine says "I am draining": inside {@link EngineSignal}`.draining`
396
+ * and as the body of a {@link DrainReceipt}.
397
+ * - `actionId` — the action that put the engine into draining (idempotency key of the drain request).
398
+ * - `since` — epoch ms when draining began; it does not move when the same action repeats.
399
+ * - `deadlineAt` — epoch ms by which the engine stops on its own.
400
+ */
401
+ export const EngineDraining = z.object({
402
+ actionId: z.string().min(1),
403
+ since: EpochMs,
404
+ deadlineAt: EpochMs,
405
+ });
406
+ /**
407
+ * Everything the ENGINE itself mints about its own state, as one object — the closed form of the engine-owned subset of the
408
+ * /health self-description ({@link InstanceHealth} is that self-description: an open record, `.passthrough()`, which also
409
+ * carries what the host adds — build, pid, port, store posture). One mint point on the engine; {@link readEngineSignal} reads
410
+ * this shape off a body whose keys sit flat on it (no wrapper key).
411
+ *
412
+ * Where it rides today: on NEITHER exit whole. The heartbeat ({@link InstanceAnnouncement}) and `/health` ({@link InstanceHealth})
413
+ * still speak the 8.0 dialect for the keys they share with this shape, in three ways:
414
+ * 1. `draining` is a BOOLEAN there, not the {@link EngineDraining} object. The heartbeat always carries it (`false` included);
415
+ * `/health` carries `draining: true` only while draining.
416
+ * 2. `/health` carries `ready` only while NOT ready (`ready: false`); a ready engine's `/health` body has no `ready` key.
417
+ * 3. The engine writes `configTargetVersion` / `configAppliedVersion` as numbers; this shape types them as strings.
418
+ * So `readEngineSignal` over a heartbeat body is always `undefined` (point 1), and over a `/health` body it is `undefined` whenever
419
+ * any of the three applies. What does ride both exits flat, under these names and with these values: `restart`, `instanceId`
420
+ * and the fleet-membership keys `generation` / `leaseState` / `leaseUntil` — on the heartbeat, read them off the parsed
421
+ * {@link InstanceAnnouncement} (which types all five). The next major retires the boolean pair (`draining` / `drainingSince`),
422
+ * has both exits carry this shape whole and moves {@link isReadyForTraffic} / {@link isRoutable} onto it; until then this is
423
+ * the target form, and `readEngineSignal` is the reader for a body that already speaks it.
424
+ *
425
+ * - `ready` — always said (never "absent while ready"): the engine can take new work right now.
426
+ * - `draining` — present ⇔ draining ({@link EngineDraining}). NOTE the dialect: {@link InstanceHealth}`.draining` and
427
+ * {@link InstanceAnnouncement}`.draining` are BOOLEANS (the 8.0 dialect, kept untouched); here it is an object. A body
428
+ * whose `draining` is a boolean is therefore not an `EngineSignal` and reads as `undefined`.
429
+ * - `restart` — the one {@link RestartSignal}, same schema object, no copy.
430
+ * - `generation` — echo of the env the config renderer set on this instance ({@link FLEET_GENERATION_ENV}), a canonical
431
+ * decimal string ({@link isCanonicalGeneration}). Absent ⇔ not a fleet member (a single-machine engine): it takes no lease
432
+ * and is not fenced.
433
+ * - `instanceId` — the engine's OWN process identity, minted by the engine every time the process starts (never read from
434
+ * env: replicas of one Deployment share one env, and a restarted container reuses its env, so env cannot tell two
435
+ * processes apart). It is present on a standalone engine too — only `generation` marks fleet membership.
436
+ * - `leaseState` — the serving lease as the engine holds it ({@link LeaseState}: `none` / `granted` / `expired` / `denied`);
437
+ * present ⇔ fleet member (the engine says it on every beat once it has a generation).
438
+ * - `leaseUntil` — epoch ms until which the engine holds its serving lease, as the engine computed it from the last
439
+ * `ttlMs` it was granted ({@link ServingLeaseResponse}); present ⇔ `leaseState` is `granted`.
440
+ * - `configTargetVersion` / `configAppliedVersion` — the config-center version the engine was told to converge on, and the
441
+ * one it has applied (opaque strings; equal ⇔ converged).
442
+ *
443
+ * Reading: `EngineSignal` is parsed with the default strip — a whole /health body goes in, only these keys come out. A
444
+ * shape violation in ANY key makes the whole signal `undefined` (it is never "the rest of it"). `undefined` means "no
445
+ * signal this reader can say" — an engine that predates the keys, or a malformed body — and is NEVER "ready": a caller on
446
+ * the routing axis treats it as not routable unless it deliberately falls back to the boolean dialect of
447
+ * {@link InstanceHealth}.
448
+ */
449
+ export const EngineSignal = z.object({
450
+ ready: z.boolean(),
451
+ draining: EngineDraining.optional(),
452
+ restart: RestartSignal.optional(),
453
+ generation: Generation.optional(),
454
+ instanceId: z.string().min(1).optional(),
455
+ leaseState: LeaseState.optional(),
456
+ leaseUntil: EpochMs.optional(),
457
+ configTargetVersion: z.string().min(1).optional(),
458
+ configAppliedVersion: z.string().min(1).optional(),
459
+ });
460
+ /**
461
+ * The reader of {@link EngineSignal}: the shape's keys, flat on the given body, judged by the same guarded body reader as
462
+ * {@link readRestartSignal} and {@link readModelRefusals}, so the same odd input gets the same answer from all three. Shape
463
+ * mismatch, a missing `ready`, a non-object body, even a throwing accessor ⇒ `undefined` — never throws. See {@link EngineSignal}
464
+ * for what `undefined` means (not "ready") and for why today's heartbeat and `/health` bodies read as `undefined` here.
465
+ */
466
+ export function readEngineSignal(health) {
467
+ return readSignalBody(health, wholeBody, EngineSignal);
468
+ }
469
+ /**
470
+ * The env name a fleet member's generation travels under — the config renderer sets it, the engine reads it back and echoes
471
+ * it as {@link EngineSignal}`.generation`. Both sides import THIS constant; neither spells the name. The value is the
472
+ * generation this instance belongs to (opaque string); absent from the engine's env ⇔ not a fleet member (the standalone
473
+ * form). There is deliberately NO env name for the instance id: an instance's identity is minted by the engine per process
474
+ * ({@link EngineSignal}`.instanceId`), and env cannot carry it (see there).
475
+ */
476
+ export const FLEET_GENERATION_ENV = "FLEET_GENERATION";
477
+ /**
478
+ * The serving lease — the ONE fence between a superseded generation and the traffic it must stop taking. A fleet-member
479
+ * engine renews it with the center; the center answers per the lifecycle object it holds for that instance. Three constants
480
+ * fix the clock, all in ms:
481
+ * - {@link SERVING_LEASE_TTL_DEFAULT_MS} — the lease length when the operator sets none (10 min).
482
+ * - {@link SERVING_LEASE_TTL_FLOOR_MS} — the shortest lease there is: a configured TTL below it is treated as it (1 min),
483
+ * the same way {@link DRAIN_GRACE_FLOOR_MS} protects a fat-fingered grace.
484
+ * - {@link SERVING_LEASE_DRIFT_MARGIN_MS} — the center's clock-skew allowance: it adds this to a lease's recorded expiry
485
+ * before it counts that lease as over. It is DERIVED from {@link DEFAULT_HEARTBEAT_MS} (one cadence, one number), never a
486
+ * second constant that could drift from it.
487
+ */
488
+ export const SERVING_LEASE_TTL_DEFAULT_MS = 600_000;
489
+ export const SERVING_LEASE_TTL_FLOOR_MS = 60_000;
490
+ export const SERVING_LEASE_DRIFT_MARGIN_MS = DEFAULT_HEARTBEAT_MS;
491
+ /**
492
+ * The ONE place a configured lease TTL becomes a usable one: not a number (absent, `NaN`, infinite, another type) ⇒
493
+ * {@link SERVING_LEASE_TTL_DEFAULT_MS}; a number below {@link SERVING_LEASE_TTL_FLOOR_MS} (zero and negatives included) ⇒ the
494
+ * floor; otherwise the number, truncated to whole ms, with no ceiling. It is for the ISSUING side's configuration. A reply's
495
+ * `ttlMs` is used exactly as granted and is never clamped upward — an engine that stretched a short grant to the floor would
496
+ * hold the lease past what the center recorded, which is the one thing the fence exists to prevent.
497
+ */
498
+ export function clampServingLeaseTtlMs(configuredMs) {
499
+ if (typeof configuredMs !== "number" || !Number.isFinite(configuredMs))
500
+ return SERVING_LEASE_TTL_DEFAULT_MS;
501
+ return Math.max(SERVING_LEASE_TTL_FLOOR_MS, Math.floor(configuredMs));
502
+ }
503
+ /**
504
+ * Engine → center: "may this instance keep serving?" The identity triple names the instance exactly — a lease is never
505
+ * asked for by worker name alone. `generation` is the env echo ({@link FLEET_GENERATION_ENV}); `instanceId` is the engine's
506
+ * own per-process identity ({@link EngineSignal}`.instanceId`), so a restarted process is a NEW instance to the center.
507
+ * `scope` — the tenant scope the lease is asked in. A renewal is taken on a PER-WORKER token only — the credential binds
508
+ * (scope, worker) — so the credential decides the scope and this field can only agree: absent = the scope the token binds; present
509
+ * ⇒ it must equal that scope (a mismatch is refused, never rewritten either way). The shared token may not renew a lease at all
510
+ * (the center answers 403). Only fleet members ask: an engine without a generation (the standalone form) has no lease and sends
511
+ * nothing.
512
+ */
513
+ export const ServingLeaseRequest = z.object({
514
+ worker: z.string().min(1),
515
+ scope: z.string().min(1).optional(),
516
+ generation: Generation,
517
+ instanceId: z.string().min(1),
518
+ });
519
+ /**
520
+ * Why a lease is refused — a CLOSED set of exactly two words, owned here:
521
+ * - `superseded` — a newer generation of this worker has taken over; this instance is the old one.
522
+ * - `retired` — the center has retired this instance (or the worker it belongs to).
523
+ * Only {@link ServingLeaseResponse}'s `denied` arm carries a reason; `unknown` has none, by shape.
524
+ */
525
+ export const SERVING_LEASE_DENIED_REASONS = ["superseded", "retired"];
526
+ export const ServingLeaseDeniedReason = z.enum(SERVING_LEASE_DENIED_REASONS);
527
+ const ServingLeaseGranted = z.object({ verdict: z.literal("granted"), ttlMs: z.number().int().positive() });
528
+ const ServingLeaseDenied = z.object({ verdict: z.literal("denied"), reason: ServingLeaseDeniedReason });
529
+ const ServingLeaseUnknown = z.object({ verdict: z.literal("unknown") });
530
+ /**
531
+ * Center → engine: the verdict, a CLOSED set of three words ({@link SERVING_LEASE_VERDICTS}) — the arms of one
532
+ * discriminated union, so a reader that switches on `verdict` is told by the compiler when a word is added.
533
+ * - `granted` — keep serving; `ttlMs` is how long the lease lasts, a DURATION, never a wall-clock instant: the engine counts
534
+ * it from the instant it SENT the request, on its own monotonic clock (`expiresAt = sendAt + ttlMs`), and the center keeps
535
+ * its own expiry from its store time — the two sides never compare clocks, so no clock agreement is needed.
536
+ * - `denied` — this instance must stop: `reason` is {@link ServingLeaseDeniedReason}. The ONLY arm that obliges an engine to
537
+ * stop serving, and it is issued on POSITIVE evidence only: the center keeps, per (scope, worker), a persisted generation
538
+ * floor that is never garbage-collected, and a request whose generation lies below it is `superseded`; `retired` likewise
539
+ * rests on a record the center holds. A center that merely holds no such record does not deny — it answers `unknown`.
540
+ * - `unknown` — the center holds no lifecycle object for this instance right now (restoring its store, migrating scopes, not
541
+ * yet claimed). It says NOTHING about whether the instance should serve, so it is not a kind of `denied` and absence of the
542
+ * object NEVER turns into a denial: it carries no reason, and an engine never drains on it. To the engine it is the same
543
+ * case as an unreachable center.
544
+ * This is the law for what a center SENDS. A reader does not apply it to `reason`: {@link readServingLeaseResponse} judges by
545
+ * the `verdict` key and keeps a `denied` a `denied` even when its reason is outside the set ({@link ServingLeaseReading}). A
546
+ * reply whose `verdict` itself is outside the three words (or a `granted` with no valid `ttlMs`) is not a verdict at all and
547
+ * reads as `undefined` — an unreachable center, never `denied`, never `granted`.
548
+ */
549
+ export const ServingLeaseResponse = z.discriminatedUnion("verdict", [ServingLeaseGranted, ServingLeaseDenied, ServingLeaseUnknown]);
550
+ /** The reason word a READER puts in place of a `denied` reason it cannot place (outside {@link SERVING_LEASE_DENIED_REASONS},
551
+ * missing, not a string). Read-face only: a producer never sends it, and the strict {@link ServingLeaseResponse} refuses it. */
552
+ export const SERVING_LEASE_UNKNOWN_REASON = "unknown_reason";
553
+ const isServingLeaseDeniedReason = (w) => SERVING_LEASE_DENIED_REASONS.includes(w);
554
+ /** What a reader makes of the `denied` arm: it is judged by the `verdict` key ALONE, and `reason` is diagnostic — a reason it
555
+ * can place is kept, anything else becomes {@link SERVING_LEASE_UNKNOWN_REASON}. The arm is still `denied`. */
556
+ const ServingLeaseDeniedRead = z.object({
557
+ verdict: z.literal("denied"),
558
+ reason: z.unknown().transform((r) => (isServingLeaseDeniedReason(r) ? r : SERVING_LEASE_UNKNOWN_REASON)),
559
+ });
560
+ /** The read face of {@link ServingLeaseResponse} — same three arms, with the `denied` arm tolerant about `reason`. */
561
+ const ServingLeaseReadFace = z.discriminatedUnion("verdict", [ServingLeaseGranted, ServingLeaseDeniedRead, ServingLeaseUnknown]);
562
+ /** The verdict words as a runtime list. `satisfies` bites a word the union does not have; the converse (a union arm missing
563
+ * from this list) is pinned by a test that reads the union's discriminators. */
564
+ export const SERVING_LEASE_VERDICTS = ["granted", "denied", "unknown"];
565
+ /** Center side: the request body of a serving-lease renewal. Shape mismatch / non-object / throwing accessor ⇒ `undefined`,
566
+ * never throws (same guarded reader as every other read in this file). */
567
+ export function readServingLeaseRequest(body) {
568
+ return readSignalBody(body, wholeBody, ServingLeaseRequest);
569
+ }
570
+ /** Engine side: the reply to a renewal, judged by its `verdict` key: `granted` needs a valid `ttlMs`, `denied` is `denied`
571
+ * whatever its `reason` (see {@link ServingLeaseReading}), `unknown` is `unknown`. A non-object, a throwing accessor, a
572
+ * `verdict` outside the three words or a `granted` without a valid `ttlMs` ⇒ `undefined`, never throws — and `undefined`
573
+ * is read like an unreachable center, never like `denied` and never like `granted`. */
574
+ export function readServingLeaseResponse(body) {
575
+ return readSignalBody(body, wholeBody, ServingLeaseReadFace);
576
+ }
577
+ /**
578
+ * What the CENTER says it can do: capability bits an engine reads before it starts using a center-side endpoint.
579
+ * - `servingLease` — the center answers serving-lease renewals ({@link ServingLeaseRequest} / {@link ServingLeaseResponse}).
580
+ *
581
+ * A bit is `true` or absent — one spelling for "not supported" (there is no `false`). Absent (an older center, a body without
582
+ * the key, a body that cannot be read) means the center does not support the lease, and the engine then neither renews a
583
+ * lease nor is fenced by one: this is the compatibility gate that lets an executor ship ahead of the center without pushing
584
+ * running engines into lease expiry. It is a gate, not a verdict — nothing in this shape says "the center refuses leases".
585
+ * A reader asks `readCenterCapabilities(body)?.servingLease === true` and starts the lease client only on that.
586
+ * Unknown bits are stripped (a newer center's extra bits never break an older reader); a known bit whose value is not `true`
587
+ * makes the whole read `undefined`, which reads exactly as absent.
588
+ * It rides in ONE place: the heartbeat's 2xx reply ({@link FLEET_PATHS}`.instances`), under {@link HEARTBEAT_REPLY_CAPABILITIES_KEY}.
589
+ * The center answers the bits per CALLER CREDENTIAL: under a per-worker token, by what the center supports; under the shared token
590
+ * `servingLease` is always absent (the serving-lease endpoint takes per-worker tokens only, so announcing it there would only push
591
+ * that caller into the unreachable arm).
592
+ */
593
+ export const CenterCapabilities = z.object({
594
+ servingLease: z.literal(true).optional(),
595
+ });
596
+ /**
597
+ * The key of the heartbeat's 2xx reply body under which the center puts its {@link CenterCapabilities} — the object's only carrier.
598
+ * The center writes `{ …, [HEARTBEAT_REPLY_CAPABILITIES_KEY]: caps }`; the engine reads
599
+ * `readCenterCapabilities(reply[HEARTBEAT_REPLY_CAPABILITIES_KEY])`, and only on a 2xx (a failed heartbeat says nothing about the
600
+ * bits). Nested, not flat: the reply's own keys and the capability bits never share a namespace. A reply without the key is an
601
+ * older center — no capability said.
602
+ */
603
+ export const HEARTBEAT_REPLY_CAPABILITIES_KEY = "capabilities";
604
+ /** Engine side: the center's capability object. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws
605
+ * (and `undefined` means "no capability said" — the same as every bit absent). */
606
+ export function readCenterCapabilities(body) {
607
+ return readSignalBody(body, wholeBody, CenterCapabilities);
608
+ }
609
+ /**
610
+ * The drain endpoint's request: "put this instance into draining, by this deadline". One persisted action, one validation,
611
+ * whether the center calls directly or an executor retries it. Two lanes, one shape: on the Docker lane (and for a direct
612
+ * call from the center) this HTTP request is how an instance is drained; on the Kubernetes lane no per-pod HTTP call is made
613
+ * (per-pod addressing and RBAC make it the wrong tool) — an instance is drained by its serving lease being denied
614
+ * ({@link ServingLeaseResponse}), after which the engine puts itself into draining.
615
+ * - `actionId` — idempotency key: the same id again echoes the first answer ({@link DrainReceipt}`.state` =
616
+ * `already_draining`) and changes nothing.
617
+ * - the identity — `scope?` / `worker` / `expectedGeneration?` / `instanceId?` — names WHICH instance the action is for. The
618
+ * engine compares it field by field against its own, absent against absent: an engine that is not a fleet member has no
619
+ * generation, so a request with no `expectedGeneration` is the standalone form, not a wildcard; `instanceId`, when given,
620
+ * is the engine's own per-process identity ({@link EngineSignal}`.instanceId`). A mismatch is the endpoint's refusal, not
621
+ * this shape's: this schema only says what a well-formed request looks like.
622
+ * - `deadlineAt` — epoch ms by which the engine stops on its own.
623
+ * - `term` — the claim term the sending executor holds for this action (center-issued, opaque); absent where no executor
624
+ * sits in between (a direct call, a hand-issued action).
625
+ */
626
+ export const DrainRequest = z.object({
627
+ actionId: z.string().min(1),
628
+ scope: z.string().min(1).optional(),
629
+ worker: z.string().min(1),
630
+ expectedGeneration: Generation.optional(),
631
+ instanceId: z.string().min(1).optional(),
632
+ deadlineAt: EpochMs,
633
+ term: z.string().min(1).optional(),
634
+ });
635
+ /** What a drain request leaves the engine in: `draining` — this call put it into draining; `already_draining` — it already was
636
+ * (the same action repeated), and the receipt reports the first call's facts. */
637
+ export const DRAIN_RECEIPT_STATES = ["draining", "already_draining"];
638
+ export const DrainReceiptState = z.enum(DRAIN_RECEIPT_STATES);
639
+ /** The drain endpoint's answer: the engine's draining fact ({@link EngineDraining} — the same object {@link EngineSignal}
640
+ * carries as `draining`, extended, not copied) plus how this call found the engine. */
641
+ export const DrainReceipt = EngineDraining.extend({ state: DrainReceiptState });
642
+ /** Engine side: the request body of the drain endpoint. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws. */
643
+ export function readDrainRequest(body) {
644
+ return readSignalBody(body, wholeBody, DrainRequest);
645
+ }
646
+ /** Caller side: the drain endpoint's answer. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws. */
647
+ export function readDrainReceipt(body) {
648
+ return readSignalBody(body, wholeBody, DrainReceipt);
649
+ }
276
650
  // ── Domain 3: dispatch policy (traffic + failover + LB — three domains, one gateway) ──────────────────────
277
651
  export const DISPATCH_STRATEGIES = ["round-robin", "least-inflight", "consistent-hash"];
278
652
  /** Per-worker dispatch policy (center-configured; the gateway is OPTIONAL — D3: TOC/self-maintained TOB go
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The ONE rendering of a zod issue into a sentence that may leave in a report which promises to name fields, never values (the
3
+ * workers migration's `refused[].reason`, the `migrate-workers` CLI report). Internal — not on any package subpath.
4
+ *
5
+ * zod's own messages name the expected shape, not the input, with one exception: the default `invalid_enum_value` message ends in
6
+ * `, received '<the value>'`. That issue is re-worded from its options alone (the closed set it expected); every other issue keeps
7
+ * its message as is. The result still goes through the usual secret redaction where the caller applies it.
8
+ */
9
+ import type { z } from "zod";
10
+ export declare function issueText(issue: z.ZodIssue): string;
@@ -0,0 +1,8 @@
1
+ export function issueText(issue) {
2
+ switch (issue.code) {
3
+ case "invalid_enum_value":
4
+ return `Invalid enum value. Expected ${issue.options.map((o) => `'${String(o)}'`).join(" | ")}`;
5
+ default:
6
+ return issue.message;
7
+ }
8
+ }
@@ -21,15 +21,22 @@
21
21
  * (无 host、无 `DB_BACKEND`、无 `tidb` 词)⇒ 不写店,旧字段 dropped。
22
22
  * · **表达不了 ⇒ refused**:值写错了栏(密钥以字面值写在 env、坐标来自宿主密钥)、不认识的词、新旧两形并存、`DB_BACKEND=pg`
23
23
  * 配 MySQL 族坐标 —— 迁移不搬值进密钥位、不读宿主密钥、不替运维挑赢家、不换库。
24
- * · **终判 = 7.0.0 schema**:迁出来的每只 worker 都过 `WorkerSpec.safeParse`;不过 ⇒ refused,拒因是 schema 自己的 issue
25
- * (例:多租户 + host 车道、点了车道却缺必需坐标)。迁移不复述任何一条写闸规则。
24
+ *
25
+ * ## 终判(逐行:迁过的行与没动的行同一只)
26
+ * 每一只**认得出名字**的 worker —— 迁过的、没动的一样 —— 都过读侧自己的判据:**只含这一行的 workers 域**读不读得进
27
+ * (`WorkersConfig`,就是 `parseDomain("workers")` 用的那一只 schema)。读不进 ⇒ refused,拒因是 schema 自己的 issue(例:多租户 + host
28
+ * 车道、点了车道却缺必需坐标、`env` 里设了保留名、`replicas` 与 `autoscaling` 并存)。迁移不复述任何一条写闸规则,也不另抄保留名表:
29
+ * 判据只有读侧那一只。所以 `doc` 里每一只认得出名字的 worker 单独都读得进。
30
+ *
31
+ * 整域读侧仍是**最后围栏**,只剩两类归它:认不出名字的条目(不是对象 / 没有字符串名字 —— 迁移不判形,原样留在 `doc`)与跨行规则
32
+ * (名字 / 路由前缀唯一 —— 两行单独都读得进,合起来才不行,迁移不替运维挑留哪一行)。
26
33
  *
27
34
  * refused 的 worker **不进**输出 `doc`(中心不加载它、在列表标红点名 `field`;CLI 有 refused 就一字不写),但随 `refused[].original`
28
35
  * 逐字带回:`doc` ∪ `refused[].original` 覆盖输入的全部 worker,中心写回前按 `index` 放回(或另存),运维的 spec 不会随迁移消失。
29
36
  * 拒因 / dropped 只点名字段,不回显值。
30
37
  *
31
38
  * ## 幂等
32
- * 已迁形(没有任何旧键、该有的块都在)⇒ 原样(同一引用)返回、`changed = []`。所以再跑一次 ⇒ 零变化、`doc` 字节相等。
39
+ * 已迁形且逐行读得进(没有任何旧键、该有的块都在)⇒ 原样(同一引用)返回、`changed = []`。所以再跑一次 ⇒ 零变化、`doc` 字节相等。
33
40
  */
34
41
  import { type ConfigPath } from "./config-fns.js";
35
42
  /** 一个被丢掉的旧字段(引擎本就不读它 —— 通知一行,不是错误)。`field` 相对该 worker 条目;`index` = 它在输入 `workers` 里的下标。 */
@@ -38,7 +45,8 @@ export interface WorkersMigrationDrop {
38
45
  readonly index: number;
39
46
  readonly field: ConfigPath;
40
47
  }
41
- /** 一只迁不了的 worker(不进输出 `doc`)。`field` 指到要人改的那一格(相对该 worker 条目;终判失败时是 7.0.0 形里的路径),
48
+ /** 一只迁不了的 worker(不进输出 `doc`)。`field` 指到要人改的那一格(相对该 worker 条目;终判拒时是读侧 schema issue 的路径 ——
49
+ * 迁过的行指迁出形里的格,没动的行指存下来那一行的格),
42
50
  * `reason` 只点名字段、不回显值。`original` = 输入里那一行**逐字**(同一个值,未拷贝未改写)——
43
51
  * 🔴 **写回 `doc` 之前必须把每个 `refused[].original` 按 `index` 放回(或另存)**,否则这只 worker 的 spec 随写回从存储里消失,
44
52
  * 运维看到的是「worker 不见了」而不是「worker 被标红」。它是调用方自己的数据,原样带回;要记日志,按调用方记那一行的规矩记。 */