diff --git a/.changeset/17780-plugin-lifecycle-duration-units.md b/.changeset/17780-plugin-lifecycle-duration-units.md new file mode 100644 index 0000000000..2702e1b133 --- /dev/null +++ b/.changeset/17780-plugin-lifecycle-duration-units.md @@ -0,0 +1,82 @@ +--- +"@objectstack/spec": minor +"@objectstack/core": minor +--- + +feat(spec)!: the three `kernel/plugin-lifecycle-advanced.zod.ts` duration keys carry their unit in the key name (#17780, ruling A on #15939) + + + +**BREAKING** — the health-check period, the health-check deadline and the hot-reload debounce +now carry `Ms` in the key name. + +| | before | after | +|:--|:--|:--| +| `PluginHealthCheck` | `interval: 30000` | `intervalMs: 30000` | +| `PluginHealthCheck` | `timeout: 5000` | `timeoutMs: 5000` | +| `HotReloadConfig` | `debounceDelay: 1000` | `debounceDelayMs: 1000` | +| values, defaults, min bounds | ms; 30000 / 5000 / 1000; min 1000 / 100 / 0 | **unchanged** | + +## Migration + +```diff + const health = PluginHealthCheckSchema.parse({ +- interval: 30000, +- timeout: 5000, ++ intervalMs: 30000, ++ timeoutMs: 5000, + }); + + hotReload.registerPlugin('my-plugin', { +- debounceDelay: 1000, ++ debounceDelayMs: 1000, + }); +``` + +Rename the keys. Every value is the same number of milliseconds it always was, and the +30000 / 5000 / 1000 defaults are unchanged; nothing else on either def moves. + +## Why + +Each key named milliseconds in a source JSDoc — "Health check interval in milliseconds", +"Timeout for health check in milliseconds", "Debounce delay before reloading (milliseconds)" — +and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is. +Measured by the `check:duration-unit-keys` census on this tree, all three read +`[name: -] [prose: -]`: no unit in the name and none in the published prose either. +`interval` was the sharpest of the three — its describe carried one unit-shaped token, the +parenthetical "(default: 30s)", naming SECONDS for a value the schema bounds and defaults in +MILLISECONDS. Executes director-seat ruling A on #15939 (2026-09-11, maintainer 「同意」, +decision batch #115), the per-file remediation of the #14478 rule. + +The suffix is the family's own spelling, counted on this tree: 100 key-position `*Ms` +declarations across `packages/spec`, `timeoutMs` 29 of them and `intervalMs` 3. +`debounceDelay` takes the plain suffix rather than a shortened form because it is the only +debounce-shaped key spelling in the repo (no `debounceMs` variant anywhere) while the +Delay-plus-`Ms` pairing is already attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, +`delayMs`) — so unlike the `Ttl`-versus-`TTL` question the sibling round settled, there was no +competing family spelling to choose between. + +## The kit + +- a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value + reaching the parse raises the rename prescription instead of being silently stripped — + neither `PluginHealthCheckSchema` nor `HotReloadConfigSchema` is `.strict()`, and here the + stripped value would land on a `setInterval` period, a race deadline and a `setTimeout` delay +- the ADR-0087 D3 semantic entry `kernel-health-check-and-hot-reload-durations-unit-in-key` and + three `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: neither def is an authorable + surface — both are library parameters a host passes to `PluginHealthMonitor` / + `HotReloadManager` in TypeScript — so the chain has no seam that runs on them, the same + reading `plugin-auto-restart-never-reinitialised` and `hot-reload-watch-placeholder-retired` + recorded for keys on these two defs +- `@objectstack/core` moves with the rename: `PluginHealthMonitor` and `HotReloadManager` read + the suffixed keys, and each class's registration-time refusal table gains a row so a host + still passing an old spelling is answered with an ADR-0112 `VALIDATION_ERROR` / 400 naming + the rename, rather than getting `undefined` where a duration belongs +- pin tests on both schemas and both classes: the refusal carries the rename prescription, the + suffixed keys parse at the magnitude the retired ones carried with the same defaults, and the + describes publish the unit. The two minimum-bound pins were rewritten rather than left: spelled + through the bare keys they would have stayed green off the tombstone's refusal instead of the + bound, so they now assert the `too_small` issue code on the suffixed keys +- `HotReloadConfig.shutdownTimeout` is deliberately NOT renamed with them — its JSDoc reads + "Graceful shutdown timeout" and names no unit anywhere, so it is the unit-nowhere shape the + #14478 gate leaves outside its verdict, not part of this row set diff --git a/content/docs/protocol/kernel/lifecycle.mdx b/content/docs/protocol/kernel/lifecycle.mdx index 0499e2aaae..caf31253f3 100644 --- a/content/docs/protocol/kernel/lifecycle.mdx +++ b/content/docs/protocol/kernel/lifecycle.mdx @@ -693,7 +693,7 @@ export const salesforcePlugin = { const monitor = new PluginHealthMonitor(kernel.logger); // `registerPlugin` takes the PARSED config, so parse it: the schema fills in -// interval 30000, timeout 5000, failureThreshold 3 and successThreshold 1. +// intervalMs 30000, timeoutMs 5000, failureThreshold 3 and successThreshold 1. monitor.registerPlugin( salesforcePlugin.name, PluginHealthCheckSchema.parse({ checkMethod: 'healthCheck' }), @@ -702,9 +702,11 @@ monitor.registerPlugin( monitor.startMonitoring(salesforcePlugin.name, salesforcePlugin); ``` -`startMonitoring` runs one check immediately, then repeats every `interval` -milliseconds; each run is raced against `timeout`, and the method may be -synchronous or return a promise. +`startMonitoring` runs one check immediately, then repeats on the +`intervalMs` period; each run is raced against `timeoutMs`, and the method may +be synchronous or return a promise. Both keys carry their unit in the name — +they were renamed from `interval` / `timeout` in @objectstack/spec 17, and the +old spellings are refused at `registerPlugin` with the rename. Only two returned shapes count as a failure: `false`, and an object whose `status` is exactly `'unhealthy'` (whose `message`, if any, becomes the @@ -713,7 +715,7 @@ report's). **Everything else passes** — `true`, `undefined`, nowhere to publish latency or row counts: no key beyond `status` and `message` is read. Consecutive returned failures move the plugin to `degraded` first, and to `unhealthy` only once `failureThreshold` of them accumulate. A check that -**throws** — including one that exceeds `timeout` — is the separate `failed` +**throws** — including one that exceeds `timeoutMs` — is the separate `failed` status, applied immediately with no threshold. Recovery is the mirror of that half, and `successThreshold` is its counter: the @@ -787,7 +789,7 @@ this shape over HTTP — it is an in-process model, not a wire body. | :--- | :--- | | the plugin's configured `checkMethod` | the custom check ran and returned — `"passed"`, or `"failed"` for the two failing shapes above | | `"plugin-loaded"` | no `checkMethod` is configured, **or** the configured name does not resolve to a function on the plugin | -| `"health-check"` | the check **threw** — a `timeout` overrun included, since the race surfaces it as a rejection. A fixed name, neither the method's nor the default's, and always `status: "failed"` | +| `"health-check"` | the check **threw** — a `timeoutMs` overrun included, since the race surfaces it as a rejection. A fixed name, neither the method's nor the default's, and always `status: "failed"` | `metrics.uptimeMs` is in **milliseconds** (`Date.now() - startTime`), unlike the seconds-valued `uptime` of `GET /health` above — which is the very diff --git a/content/docs/references/kernel/plugin-lifecycle-advanced.mdx b/content/docs/references/kernel/plugin-lifecycle-advanced.mdx index 8874201504..a269bea142 100644 --- a/content/docs/references/kernel/plugin-lifecycle-advanced.mdx +++ b/content/docs/references/kernel/plugin-lifecycle-advanced.mdx @@ -42,7 +42,8 @@ const result = HotReloadConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | | | **watchPatterns** | `never` | optional | [REMOVED] `HotReloadConfig.watchPatterns` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing ever read it. Its only two uses were log lines in `HotReloadManager`, and one of them announced 'File watching started' at INFO level while no watcher was ever constructed: `startWatching` held a placeholder, and `watchHandles` was read, deleted, iterated and cleared but never set. So an author could declare a glob and no file change could ever trigger a reload. Delete the key. File watching is the HOST's job in this host-driven library: run your own watcher, declare your globs wherever that watcher reads them, and call `HotReloadManager.scheduleReload(pluginName, reloadFn)` when one matches — the debounced integration point this class does implement, and which is unchanged. | -| **debounceDelay** | `integer` | optional (default: `1000`) | Wait time after change detection before reload | +| **debounceDelayMs** | `integer` | optional (default: `1000`) | Wait time after change detection before reload, in milliseconds | +| **debounceDelay** | `never` | optional | [REMOVED] `HotReloadConfig.debounceDelay` was renamed to `debounceDelayMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Its unit (milliseconds) lived in a source JSDoc only and the published describe named none, so the reference-page reader got a bare 1000. Rename the key to `debounceDelayMs`; the value (milliseconds) and the 1000 default are unchanged. | | **preserveState** | `boolean` | optional (default: `true`) | Keep plugin state across reloads | | **stateStrategy** | `Enum<'memory' \| 'none'>` | optional (default: `"memory"`) | How to preserve state during reload | | **shutdownTimeout** | `integer` | optional (default: `30000`) | Maximum time to wait for graceful shutdown | @@ -58,14 +59,16 @@ const result = HotReloadConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **interval** | `integer` | optional (default: `30000`) | How often to perform health checks (default: 30s) | -| **timeout** | `integer` | optional (default: `5000`) | Maximum time to wait for health check response | +| **intervalMs** | `integer` | optional (default: `30000`) | How often to perform health checks, in milliseconds | +| **timeoutMs** | `integer` | optional (default: `5000`) | Maximum time to wait for health check response, in milliseconds | | **failureThreshold** | `integer` | optional (default: `3`) | Consecutive failures needed to mark unhealthy | | **successThreshold** | `integer` | optional (default: `1`) | Consecutive successes needed to mark healthy | | **checkMethod** | `string` | optional | Method name to call for health check | | **autoRestart** | `never` | optional | [REMOVED] `PluginHealthCheck.autoRestart` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never restarted a plugin.A `PluginHealthMonitor` never restarted anything. `attemptRestart` called `plugin.destroy()` and stopped there — the in-source comment said "Call destroy and init to restart", but `init` appeared in `health-monitor.ts` ONLY inside that comment. What a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing against the destroyed instance — which the default check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no `checkMethod` resolves) passes forever, so the terminal report on a destroyed, never-re-initialised plugin was `healthy`. Delete the key. Restarting a plugin is the HOST's job in this host-driven library, and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private; `KernelBase.createContext` is protected). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime — recreate the kernel, or let your supervisor restart the process. The monitor reports; it does not act. | | **maxRestartAttempts** | `never` | optional | [REMOVED] `PluginHealthCheck.maxRestartAttempts` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it capped a restart that never happened.A `PluginHealthMonitor` never restarted anything. `attemptRestart` called `plugin.destroy()` and stopped there — the in-source comment said "Call destroy and init to restart", but `init` appeared in `health-monitor.ts` ONLY inside that comment. What a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing against the destroyed instance — which the default check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no `checkMethod` resolves) passes forever, so the terminal report on a destroyed, never-re-initialised plugin was `healthy`. The cap counted destroy calls, so raising it only scheduled further "restarts" of a plugin that was never brought back up. Delete the key. Restarting a plugin is the HOST's job in this host-driven library, and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private; `KernelBase.createContext` is protected). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime — recreate the kernel, or let your supervisor restart the process. The monitor reports; it does not act. | | **restartBackoff** | `never` | optional | [REMOVED] `PluginHealthCheck.restartBackoff` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it delayed a restart that never happened.A `PluginHealthMonitor` never restarted anything. `attemptRestart` called `plugin.destroy()` and stopped there — the in-source comment said "Call destroy and init to restart", but `init` appeared in `health-monitor.ts` ONLY inside that comment. What a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing against the destroyed instance — which the default check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no `checkMethod` resolves) passes forever, so the terminal report on a destroyed, never-re-initialised plugin was `healthy`. The chosen strategy only moved when the destroy landed. Delete the key. Restarting a plugin is the HOST's job in this host-driven library, and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private; `KernelBase.createContext` is protected). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime — recreate the kernel, or let your supervisor restart the process. The monitor reports; it does not act. | +| **interval** | `never` | optional | [REMOVED] `PluginHealthCheck.interval` was renamed to `intervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Its unit (milliseconds) lived in a source JSDoc only, and the published describe named no unit: its one unit-shaped token was the parenthetical "(default: 30s)", which names SECONDS for a value carried in milliseconds. Rename the key to `intervalMs`; the value (milliseconds) and the 30000 default are unchanged. | +| **timeout** | `never` | optional | [REMOVED] `PluginHealthCheck.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Its unit (milliseconds) lived in a source JSDoc only and the published describe named none, so the reference-page reader got a bare 5000. Rename the key to `timeoutMs`; the value (milliseconds) and the 5000 default are unchanged. | --- diff --git a/packages/core/src/health-monitor.test.ts b/packages/core/src/health-monitor.test.ts index 1bfde77979..4209191042 100644 --- a/packages/core/src/health-monitor.test.ts +++ b/packages/core/src/health-monitor.test.ts @@ -20,8 +20,8 @@ describe('PluginHealthMonitor', () => { it('should register plugin for health monitoring', () => { const config: PluginHealthCheckParsed = { - interval: 5000, - timeout: 1000, + intervalMs: 5000, + timeoutMs: 1000, failureThreshold: 3, successThreshold: 1, }; @@ -32,8 +32,8 @@ describe('PluginHealthMonitor', () => { it('should report healthy status initially', () => { const config: PluginHealthCheckParsed = { - interval: 5000, - timeout: 1000, + intervalMs: 5000, + timeoutMs: 1000, failureThreshold: 3, successThreshold: 1, }; @@ -44,8 +44,8 @@ describe('PluginHealthMonitor', () => { it('should get all health statuses', () => { const config: PluginHealthCheckParsed = { - interval: 5000, - timeout: 1000, + intervalMs: 5000, + timeoutMs: 1000, failureThreshold: 3, successThreshold: 1, }; @@ -61,8 +61,8 @@ describe('PluginHealthMonitor', () => { it('should shutdown cleanly', () => { const config: PluginHealthCheckParsed = { - interval: 5000, - timeout: 1000, + intervalMs: 5000, + timeoutMs: 1000, failureThreshold: 3, successThreshold: 1, }; @@ -78,7 +78,7 @@ describe('PluginHealthMonitor', () => { // Same shape as the kernel's startup guards (#4813, PR #4874), with one // aggravating difference: health checks are *periodic*, so an abandoned // guard is not a fixed cost paid once at boot — it is one orphaned timer per - // plugin per round, each pinning the event loop for a whole `config.timeout`. + // plugin per round, each pinning the event loop for a whole `config.timeoutMs`. // // What follows asserts the observable consequence, never the source: // "health-monitor.ts calls clearTimeout" is a tautology any refactor could @@ -86,8 +86,8 @@ describe('PluginHealthMonitor', () => { describe('Health-check timeout guard does not outlive the race (#4875)', () => { /** A guard long enough that a single orphan is unmistakable. */ const guardedConfig = (overrides: Partial = {}): PluginHealthCheckParsed => ({ - interval: 30_000, - timeout: 120_000, + intervalMs: 30_000, + timeoutMs: 120_000, failureThreshold: 3, successThreshold: 1, checkMethod: 'healthCheck', @@ -122,7 +122,7 @@ describe('PluginHealthMonitor', () => { const config = guardedConfig(); monitor.registerPlugin('guarded-plugin', config); - const guards = await recordGuards(config.timeout, async () => { + const guards = await recordGuards(config.timeoutMs, async () => { monitor.startMonitoring('guarded-plugin', healthyPlugin(calls)); // The initial check runs immediately; wait for its report to land. @@ -176,7 +176,7 @@ describe('PluginHealthMonitor', () => { }), } as unknown as Plugin; - monitor.registerPlugin('hanging-plugin', guardedConfig({ timeout: 100 })); + monitor.registerPlugin('hanging-plugin', guardedConfig({ timeoutMs: 100 })); monitor.startMonitoring('hanging-plugin', hangingPlugin); await vi.waitFor(() => { @@ -202,7 +202,7 @@ describe('PluginHealthMonitor', () => { it('accumulates no guard across periodic rounds', async () => { const calls = { count: 0 }; - const config = guardedConfig({ interval: 1_000 }); + const config = guardedConfig({ intervalMs: 1_000 }); monitor.registerPlugin('guarded-plugin', config); const before = vi.getTimerCount(); @@ -220,7 +220,7 @@ describe('PluginHealthMonitor', () => { // Periodic checks are where this leak compounds: one orphan per round. for (let round = 0; round < 5; round++) { - await vi.advanceTimersByTimeAsync(config.interval); + await vi.advanceTimersByTimeAsync(config.intervalMs); } expect(calls.count).toBe(6); @@ -265,8 +265,8 @@ describe('PluginHealthMonitor', () => { const failingConfig = ( overrides: Partial = {} ): PluginHealthCheckParsed => ({ - interval: INTERVAL_MS, - timeout: 100, + intervalMs: INTERVAL_MS, + timeoutMs: 100, failureThreshold: 2, successThreshold: 1, checkMethod: 'healthCheck', @@ -417,9 +417,9 @@ describe('PluginHealthMonitor', () => { // Past `failureThreshold`, and past the former backoff window, twice // over: the retired path would have destroyed by now and moved the // status to `recovering`. - await vi.advanceTimersByTimeAsync(config.interval); + await vi.advanceTimersByTimeAsync(config.intervalMs); await vi.advanceTimersByTimeAsync(FORMER_RESTART_BACKOFF_MS); - await vi.advanceTimersByTimeAsync(config.interval); + await vi.advanceTimersByTimeAsync(config.intervalMs); await vi.advanceTimersByTimeAsync(FORMER_RESTART_BACKOFF_MS); expect(destroyed.count).toBe(0); @@ -442,18 +442,18 @@ describe('PluginHealthMonitor', () => { monitor.registerPlugin('hanging-plugin', config); monitor.startMonitoring('hanging-plugin', plugin); - await vi.advanceTimersByTimeAsync(config.timeout); + await vi.advanceTimersByTimeAsync(config.timeoutMs); expect(monitor.getHealthStatus('hanging-plugin')).toBe('failed'); - await vi.advanceTimersByTimeAsync(config.interval - config.timeout); - await vi.advanceTimersByTimeAsync(config.timeout); + await vi.advanceTimersByTimeAsync(config.intervalMs - config.timeoutMs); + await vi.advanceTimersByTimeAsync(config.timeoutMs); await vi.advanceTimersByTimeAsync(FORMER_RESTART_BACKOFF_MS); expect(destroyed.count).toBe(0); expect(state.alive).toBe(true); const report = monitor.getHealthReport('hanging-plugin'); - expect(report?.message).toBe(`Health check timeout after ${config.timeout}ms`); + expect(report?.message).toBe(`Health check timeout after ${config.timeoutMs}ms`); expect(report?.checks).toEqual([ { name: 'health-check', status: 'failed', message: report?.message }, ]); @@ -472,7 +472,7 @@ describe('PluginHealthMonitor', () => { await vi.advanceTimersByTimeAsync(0); expect(monitor.getHealthStatus('unhealthy-plugin')).toBe('degraded'); - await vi.advanceTimersByTimeAsync(config.interval); + await vi.advanceTimersByTimeAsync(config.intervalMs); await vi.advanceTimersByTimeAsync(FORMER_RESTART_BACKOFF_MS); expect(monitor.getHealthStatus('unhealthy-plugin')).toBe('unhealthy'); @@ -514,8 +514,8 @@ describe('PluginHealthMonitor', () => { // shape straight from the caller's hand. describe('a config still declaring a restart is REFUSED (#12032)', () => { const legalConfig = (): PluginHealthCheckParsed => ({ - interval: 30_000, - timeout: 5_000, + intervalMs: 30_000, + timeoutMs: 5_000, failureThreshold: 3, successThreshold: 1, }); @@ -586,8 +586,8 @@ describe('PluginHealthMonitor', () => { const thresholdConfig = ( overrides: Partial = {} ): PluginHealthCheckParsed => ({ - interval: INTERVAL_MS, - timeout: 100, + intervalMs: INTERVAL_MS, + timeoutMs: 100, failureThreshold: 2, successThreshold: THRESHOLD, checkMethod: 'healthCheck', @@ -813,3 +813,57 @@ describe('PluginHealthMonitor', () => { }); }); }); + +// ── [#17780] The two duration renames refuse at the door, not silently ────── +// +// `PluginHealthCheckSchema` is not `.strict()` and `registerPlugin` takes the +// parsed shape straight from the caller's hand, so a host still spelling +// `interval` / `timeout` would otherwise get `undefined` where a duration +// belongs — a `setInterval` with no period and a race with no deadline. +describe('a config still spelling the pre-rename durations is REFUSED (#17780)', () => { + let monitor: PluginHealthMonitor; + beforeEach(() => { + monitor = new PluginHealthMonitor(createLogger({ level: 'silent' })); + }); + + it.each([ + ['interval', 'intervalMs', 30_000], + ['timeout', 'timeoutMs', 5_000], + ])('refuses `%s` with an ADR-0112 envelope naming `%s`', (old, next, value) => { + const config = { + intervalMs: 30_000, + timeoutMs: 5_000, + failureThreshold: 3, + successThreshold: 1, + [old]: value, + } as unknown as PluginHealthCheckParsed; + + let caught: (Error & { code?: string; status?: number }) | undefined; + try { + monitor.registerPlugin('legacy-duration-plugin', config); + } catch (error) { + caught = error as Error & { code?: string; status?: number }; + } + + expect(caught, `${old} must be refused`).toBeDefined(); + expect(caught?.code).toBe('VALIDATION_ERROR'); + expect(caught?.status).toBe(400); + expect(caught?.message).toContain(`'${old}' was renamed to '${next}'`); + expect(caught?.message).toContain('milliseconds'); + + // Refused BEFORE anything was stored. + expect(monitor.getHealthStatus('legacy-duration-plugin')).toBeUndefined(); + expect(monitor.getAllHealthStatuses().size).toBe(0); + }); + + it('accepts the suffixed spellings (anti-vacuity)', () => { + const config = { + intervalMs: 30_000, + timeoutMs: 5_000, + failureThreshold: 3, + successThreshold: 1, + } as PluginHealthCheckParsed; + expect(() => monitor.registerPlugin('modern-plugin', config)).not.toThrow(); + expect(monitor.getHealthStatus('modern-plugin')).toBe('unknown'); + }); +}); diff --git a/packages/core/src/health-monitor.ts b/packages/core/src/health-monitor.ts index 66311420b3..41f7499486 100644 --- a/packages/core/src/health-monitor.ts +++ b/packages/core/src/health-monitor.ts @@ -57,8 +57,9 @@ function healthMonitorRefusal(message: string): Error & { code: string; status: } /** - * Keys removed from `PluginHealthCheck` in 18 (#12032) that a host may still - * be passing. + * Keys a host may still be passing that `PluginHealthCheck` no longer accepts: + * the three restart keys removed in 18 (#12032), and the two durations renamed + * in 17 (#17780) so the unit rides in the key name. * * `PluginHealthCheckSchema` is not `.strict()`, so before the tombstones zod * would have silently STRIPPED each of these — a clean parse and a setting @@ -104,6 +105,25 @@ const RETIRED_HEALTH_CHECK_KEYS: ReadonlyArray = [ + 'never happened, so it only moved when the `destroy()` landed. Delete ' + 'the key.', ], + // The two rename rows carry no tracker id: a runtime string reaches authors + // and operators who cannot resolve one. Their anchor is #17780, ruling A on + // #15939 — here in the source, where the reader who CAN resolve it looks. + [ + 'interval', + "'interval' was renamed to 'intervalMs' on PluginHealthCheck in " + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in ' + + 'the key name, not only in the describe prose. Rename the key to ' + + "'intervalMs'; the value (milliseconds) and the 30000 default are " + + 'unchanged.', + ], + [ + 'timeout', + "'timeout' was renamed to 'timeoutMs' on PluginHealthCheck in " + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in ' + + 'the key name, not only in the describe prose. Rename the key to ' + + "'timeoutMs'; the value (milliseconds) and the 5000 default are " + + 'unchanged.', + ], ]; /** @@ -199,7 +219,7 @@ export class PluginHealthMonitor { this.logger.info('Plugin registered for health monitoring', { plugin: pluginName, - interval: config.interval + intervalMs: config.intervalMs }); } @@ -224,7 +244,7 @@ export class PluginHealthMonitor { error }); }); - }, config.interval); + }, config.intervalMs); this.checkIntervals.set(pluginName, interval); this.logger.info('Health monitoring started', { plugin: pluginName }); @@ -272,8 +292,8 @@ export class PluginHealthMonitor { if (config.checkMethod && typeof (plugin as any)[config.checkMethod] === 'function') { const checkResult = await this.raceCheckTimeout( (plugin as any)[config.checkMethod](), - config.timeout, - `Health check timeout after ${config.timeout}ms` + config.timeoutMs, + `Health check timeout after ${config.timeoutMs}ms` ); if (checkResult === false || (checkResult && checkResult.status === 'unhealthy')) { @@ -445,9 +465,9 @@ export class PluginHealthMonitor { * Same shape, same reasoning as `ObjectKernel.raceStartupTimeout()` (#4813, * PR #4874): the guard used to be armed and then abandoned — when the check * won the race, its `setTimeout` stayed ref'd in the event loop for the full - * `config.timeout`. Health checks are *periodic*, so unlike the kernel's + * `config.timeoutMs`. Health checks are *periodic*, so unlike the kernel's * one-shot startup guards the orphans here accumulate: one per plugin per - * round, each pinning the loop for `config.timeout`. + * round, each pinning the loop for `config.timeoutMs`. * * Clearing on settle rather than `unref()`-ing at arm time is deliberate. * An unref'd guard also stops pinning the loop, but it stops being a guard diff --git a/packages/core/src/hot-reload.test.ts b/packages/core/src/hot-reload.test.ts index d3d536d2e6..85062be9fb 100644 --- a/packages/core/src/hot-reload.test.ts +++ b/packages/core/src/hot-reload.test.ts @@ -48,7 +48,7 @@ describe('HotReloadManager', () => { const guardedConfig = (overrides: Partial = {}): HotReloadConfigParsed => ({ enabled: true, - debounceDelay: 1000, + debounceDelayMs: 1000, preserveState: false, stateStrategy: 'none', shutdownTimeout: 120_000, @@ -225,7 +225,7 @@ describe('[#12340] stateStrategy refusal', () => { const configWith = (strategy: string): HotReloadConfigParsed => ({ enabled: true, - debounceDelay: 0, + debounceDelayMs: 0, preserveState: true, stateStrategy: strategy, shutdownTimeout: 1000, @@ -350,7 +350,7 @@ describe('[#12428] startWatching refusal and the watch-handle removal', () => { const liveConfig = (overrides: Record = {}): HotReloadConfigParsed => ({ enabled: true, - debounceDelay: 1000, + debounceDelayMs: 1000, preserveState: false, stateStrategy: 'memory', shutdownTimeout: 1000, @@ -468,3 +468,52 @@ describe('[#12428] startWatching refusal and the watch-handle removal', () => { } }); }); + +// ── [#17780] The debounce rename refuses at the door, not silently ────────── +// +// `HotReloadConfigSchema` is not `.strict()` and `registerPlugin` takes the +// config straight from the caller's hand, so a host still spelling +// `debounceDelay` would otherwise get `undefined` where a delay belongs — the +// silent strip these tables exist to prevent, here landing on a `setTimeout` +// argument. +describe('[#17780] debounceDelay -> debounceDelayMs refusal', () => { + let mgr: HotReloadManager; + beforeEach(() => { + mgr = new HotReloadManager(createRecordingLogger([])); + }); + + it('refuses a leftover `debounceDelay` with an ADR-0112 envelope and the rename', () => { + const cfg = { + enabled: true, + debounceDelay: 2000, + preserveState: true, + stateStrategy: 'memory', + shutdownTimeout: 1000, + } as unknown as HotReloadConfigParsed; + + let caught: (Error & { code?: string; status?: number }) | undefined; + try { + mgr.registerPlugin('p', cfg); + } catch (e) { + caught = e as Error & { code?: string; status?: number }; + } + expect(caught).toBeDefined(); + expect(caught?.code).toBe('VALIDATION_ERROR'); + expect(caught?.status).toBe(400); + expect(caught?.message).toContain("'debounceDelay' was renamed to 'debounceDelayMs'"); + expect(caught?.message).toContain('milliseconds'); + // This file's prescriptions name the hazard, never a tracker id. + expect(caught?.message).not.toMatch(/(? { + const cfg = { + enabled: true, + debounceDelayMs: 2000, + preserveState: true, + stateStrategy: 'memory', + shutdownTimeout: 1000, + } as unknown as HotReloadConfigParsed; + expect(() => mgr.registerPlugin('p', cfg)).not.toThrow(); + }); +}); diff --git a/packages/core/src/hot-reload.ts b/packages/core/src/hot-reload.ts index d705c7fd9a..63b12ee457 100644 --- a/packages/core/src/hot-reload.ts +++ b/packages/core/src/hot-reload.ts @@ -92,7 +92,9 @@ function assertHonouredStateStrategy(pluginName: string, strategy: unknown): voi } /** - * Keys removed from `HotReloadConfig` that a host may still be passing. + * Keys a host may still be passing that `HotReloadConfig` no longer accepts: + * the two removed in 18 (ADR-0049), and the debounce duration renamed in 17 + * (#17780) so the unit rides in the key name. * * `HotReloadConfigSchema` is not `.strict()`, so zod would silently STRIP each * of these on the parse paths that exist — a clean parse and a setting that @@ -129,6 +131,14 @@ const RETIRED_HOT_RELOAD_KEYS: ReadonlyArray = [ + '`HotReloadManager.scheduleReload(pluginName, reloadFn)` when one matches — ' + 'that is the debounced integration point this class does implement.', ], + [ + 'debounceDelay', + "'debounceDelay' was renamed to 'debounceDelayMs' on HotReloadConfig in " + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in ' + + 'the key name, not only in the describe prose. Rename the key to ' + + "'debounceDelayMs'; the value (milliseconds) and the 1000 default are " + + 'unchanged.', + ], ]; /** @@ -512,12 +522,12 @@ export class HotReloadManager { }); }); this.reloadTimers.delete(pluginName); - }, config.debounceDelay); + }, config.debounceDelayMs); this.reloadTimers.set(pluginName, timer); this.logger.debug('Reload scheduled with debounce', { plugin: pluginName, - delay: config.debounceDelay + delayMs: config.debounceDelayMs }); } diff --git a/packages/spec/authorable-defaults/kernel.json b/packages/spec/authorable-defaults/kernel.json index e37bcdb4ed..5517ecc6e0 100644 --- a/packages/spec/authorable-defaults/kernel.json +++ b/packages/spec/authorable-defaults/kernel.json @@ -41,7 +41,7 @@ "kernel/ExecutionContext:permissions = []", "kernel/ExecutionContext:positions = []", "kernel/ExtensionPoint:cardinality = \"multiple\"", - "kernel/HotReloadConfig:debounceDelay = 1000", + "kernel/HotReloadConfig:debounceDelayMs = 1000", "kernel/HotReloadConfig:enabled = false", "kernel/HotReloadConfig:preserveState = true", "kernel/HotReloadConfig:shutdownTimeout = 30000", @@ -89,9 +89,9 @@ "kernel/PluginCapability:conformance = \"full\"", "kernel/PluginDependency:optional = false", "kernel/PluginHealthCheck:failureThreshold = 3", - "kernel/PluginHealthCheck:interval = 30000", + "kernel/PluginHealthCheck:intervalMs = 30000", "kernel/PluginHealthCheck:successThreshold = 1", - "kernel/PluginHealthCheck:timeout = 5000", + "kernel/PluginHealthCheck:timeoutMs = 5000", "kernel/PluginInstallConfig:autoUpdate = false", "kernel/PluginInterface:stability = \"stable\"", "kernel/PluginLoadingState:progress = 0", diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index 194cc55140..476353c72a 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -212,7 +212,8 @@ "kernel/HealthStatus:timestamp [RETIRED]", "kernel/HotReloadConfig:afterReload", "kernel/HotReloadConfig:beforeReload", - "kernel/HotReloadConfig:debounceDelay", + "kernel/HotReloadConfig:debounceDelay [RETIRED]", + "kernel/HotReloadConfig:debounceDelayMs", "kernel/HotReloadConfig:enabled", "kernel/HotReloadConfig:preserveState", "kernel/HotReloadConfig:shutdownTimeout", @@ -466,11 +467,13 @@ "kernel/PluginHealthCheck:autoRestart [RETIRED]", "kernel/PluginHealthCheck:checkMethod", "kernel/PluginHealthCheck:failureThreshold", - "kernel/PluginHealthCheck:interval", + "kernel/PluginHealthCheck:interval [RETIRED]", + "kernel/PluginHealthCheck:intervalMs", "kernel/PluginHealthCheck:maxRestartAttempts [RETIRED]", "kernel/PluginHealthCheck:restartBackoff [RETIRED]", "kernel/PluginHealthCheck:successThreshold", - "kernel/PluginHealthCheck:timeout", + "kernel/PluginHealthCheck:timeout [RETIRED]", + "kernel/PluginHealthCheck:timeoutMs", "kernel/PluginHealthReport:checks", "kernel/PluginHealthReport:dependencies", "kernel/PluginHealthReport:message", diff --git a/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts b/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts index 0cb85f7e59..7b226ffc38 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts @@ -27,8 +27,14 @@ describe('Plugin Lifecycle Advanced Schemas', () => { describe('PluginHealthCheckSchema', () => { it('should validate health check with defaults', () => { const healthCheck = PluginHealthCheckSchema.parse({}); - expect(healthCheck.interval).toBe(30000); - expect(healthCheck.timeout).toBe(5000); + // [#17780] Renamed: these two lines pinned `interval` / `timeout`, whose + // unit lived in a source JSDoc only. Same values, same defaults; the + // names now carry the milliseconds. The old spellings are tombstoned and + // their refusal is pinned at the bottom of this file. + expect(healthCheck.intervalMs).toBe(30000); + expect(healthCheck.timeoutMs).toBe(5000); + expect(healthCheck).not.toHaveProperty('interval'); + expect(healthCheck).not.toHaveProperty('timeout'); expect(healthCheck.failureThreshold).toBe(3); expect(healthCheck.successThreshold).toBe(1); // [#12032] The three restart defaults ASSERTED HERE ARE GONE — declared, @@ -51,8 +57,9 @@ describe('Plugin Lifecycle Advanced Schemas', () => { // true right up to the moment it stopped meaning anything at runtime, // and it never meant anything at runtime. const config = { - interval: 60000, - timeout: 10000, + // [#17780] `interval` / `timeout` renamed to carry their unit. + intervalMs: 60000, + timeoutMs: 10000, failureThreshold: 5, successThreshold: 2, checkMethod: 'healthCheck', @@ -110,12 +117,23 @@ describe('Plugin Lifecycle Advanced Schemas', () => { expect(result.success && result.data).not.toHaveProperty('somethingElse'); }); - it('should enforce minimum interval', () => { - expect(() => PluginHealthCheckSchema.parse({ interval: 500 })).toThrow(); + // [#17780] These two pinned the MIN BOUND through the bare spellings. Left + // as they were they would have stayed green off the tombstone's refusal + // instead of the bound — a pin that can no longer fail. They pin the + // suffixed keys now, and the bound is asserted by issue code so a + // tombstone refusal could not stand in for it. + it('should enforce minimum intervalMs', () => { + const result = PluginHealthCheckSchema.safeParse({ intervalMs: 500 }); + expect(result.success).toBe(false); + expect(result.error!.issues.some((i) => i.code === 'too_small')).toBe(true); + expect(PluginHealthCheckSchema.parse({ intervalMs: 1000 }).intervalMs).toBe(1000); }); - it('should enforce minimum timeout', () => { - expect(() => PluginHealthCheckSchema.parse({ timeout: 50 })).toThrow(); + it('should enforce minimum timeoutMs', () => { + const result = PluginHealthCheckSchema.safeParse({ timeoutMs: 50 }); + expect(result.success).toBe(false); + expect(result.error!.issues.some((i) => i.code === 'too_small')).toBe(true); + expect(PluginHealthCheckSchema.parse({ timeoutMs: 100 }).timeoutMs).toBe(100); }); }); @@ -171,7 +189,10 @@ describe('Plugin Lifecycle Advanced Schemas', () => { it('should validate hot reload with defaults', () => { const config = HotReloadConfigSchema.parse({}); expect(config.enabled).toBe(false); - expect(config.debounceDelay).toBe(1000); + // [#17780] Renamed: this pinned `debounceDelay`, whose unit lived in a + // source JSDoc only. Same value, same default, unit now in the name. + expect(config.debounceDelayMs).toBe(1000); + expect(config).not.toHaveProperty('debounceDelay'); expect(config.preserveState).toBe(true); expect(config.stateStrategy).toBe('memory'); expect(config.shutdownTimeout).toBe(30000); @@ -184,7 +205,8 @@ describe('Plugin Lifecycle Advanced Schemas', () => { // quiet edit. It used to be listed here and asserted via toEqual below, // an assertion that passed precisely BECAUSE the key parsed and did // nothing. The key's departure is pinned as a STRIP in its own test. - debounceDelay: 2000, + // [#17780] `debounceDelay` renamed to carry its unit. + debounceDelayMs: 2000, preserveState: false, stateStrategy: 'memory' as const, shutdownTimeout: 60000, @@ -343,3 +365,62 @@ describe('PluginHealthReport metrics durations carry their unit (#15678)', () => expect(parsed.metrics?.activeConnections).toBe(10); }); }); + +// #17780 (ruling A on #15939, executing #14478) — the three remaining +// duration-shaped keys on this file whose unit lived in a source JSDoc only. +// `.describe()` is what `content/docs/references/**` publishes and the JSDoc +// above a key is NOT, so the reader who most needs the unit was the only one +// who never saw it. `interval`'s describe was the sharpest case: its one +// unit-shaped token was a "(default: 30s)" parenthetical naming SECONDS for a +// value carried in milliseconds. All three old spellings are `retiredKey()` +// tombstones — neither object is `.strict()`, so a bare deletion would be a +// silent strip. +describe('plugin lifecycle durations carry their unit (#17780, #14478)', () => { + it.each([ + ['interval', 'intervalMs', 60000], + ['timeout', 'timeoutMs', 10000], + ])('REFUSES the retired `PluginHealthCheck.%s` with the rename to `%s`', (old, next, value) => { + const result = PluginHealthCheckSchema.safeParse({ [old]: value }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === old); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain(`\`PluginHealthCheck.${old}\` was renamed to \`${next}\``); + }); + + it('REFUSES the retired `HotReloadConfig.debounceDelay` with the rename', () => { + const result = HotReloadConfigSchema.safeParse({ debounceDelay: 2000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'debounceDelay'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`HotReloadConfig.debounceDelay` was renamed to `debounceDelayMs`', + ); + }); + + it('accepts the suffixed keys at the magnitude the retired ones carried', () => { + const health = PluginHealthCheckSchema.parse({ intervalMs: 60000, timeoutMs: 10000 }); + expect(health.intervalMs).toBe(60000); + expect(health.timeoutMs).toBe(10000); + const reload = HotReloadConfigSchema.parse({ debounceDelayMs: 2000 }); + expect(reload.debounceDelayMs).toBe(2000); + // Unchanged defaults, read off an empty parse. + expect(PluginHealthCheckSchema.parse({}).intervalMs).toBe(30000); + expect(PluginHealthCheckSchema.parse({}).timeoutMs).toBe(5000); + expect(HotReloadConfigSchema.parse({}).debounceDelayMs).toBe(1000); + }); + + it('publishes the unit in the describe — the text the reference pages render', () => { + const health = PluginHealthCheckSchema.shape; + expect(health.intervalMs.description).toBe( + 'How often to perform health checks, in milliseconds', + ); + expect(health.timeoutMs.description).toBe( + 'Maximum time to wait for health check response, in milliseconds', + ); + expect(HotReloadConfigSchema.shape.debounceDelayMs.description).toBe( + 'Wait time after change detection before reload, in milliseconds', + ); + }); +}); diff --git a/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts b/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts index f559e8b1b7..08f871ef61 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts @@ -96,22 +96,63 @@ const RESTART_BACKOFF_RETIRED = + ' The chosen strategy only moved when the destroy landed. Delete the key. ' + RESTART_REPLACEMENT; +/** + * Prescriptions for the two health-check durations renamed in 17 (#17780, + * ruling A on #15939 executing #14478). + * + * Carry NO `os migrate meta --from 17` sentence, for the same reason the + * restart prescriptions above do not: that command replays the conversion + * chain over authored METADATA SOURCES, and `PluginHealthCheck` is a library + * parameter a host passes to `PluginHealthMonitor` in TypeScript, never an + * authored document (the #4914 / #11825 keep). Naming the command would + * promise an affordance that cannot apply. + */ +const HEALTH_CHECK_INTERVAL_RETIRED = + '`PluginHealthCheck.interval` was renamed to `intervalMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the ' + + 'key name, not only in the describe prose. Its unit (milliseconds) lived ' + + 'in a source JSDoc only, and the published describe named no unit: its ' + + 'one unit-shaped token was the parenthetical "(default: 30s)", which ' + + 'names SECONDS for a value carried in milliseconds. Rename the key to ' + + '`intervalMs`; the value (milliseconds) and the 30000 default are ' + + 'unchanged.'; + +const HEALTH_CHECK_TIMEOUT_RETIRED = + '`PluginHealthCheck.timeout` was renamed to `timeoutMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the ' + + 'key name, not only in the describe prose. Its unit (milliseconds) lived ' + + 'in a source JSDoc only and the published describe named none, so the ' + + 'reference-page reader got a bare 5000. Rename the key to `timeoutMs`; ' + + 'the value (milliseconds) and the 5000 default are unchanged.'; + /** * Plugin Health Check Configuration * Defines how to check plugin health */ export const PluginHealthCheckSchema = lazySchema(() => z.object({ /** - * Health check interval in milliseconds + * How often to perform health checks, in milliseconds. + * + * Renamed from `interval` (#17780, ruling A on #15939 executing #14478): + * the unit lived in this JSDoc only, and `.describe()` — the text + * `content/docs/references/**` publishes — named no unit, its one + * unit-shaped token being a "(default: 30s)" parenthetical that names + * SECONDS for a milliseconds value. Tombstoned rather than deleted because + * this object is not `.strict()`. */ - interval: z.number().int().min(1000).default(30000) - .describe('How often to perform health checks (default: 30s)'), + intervalMs: z.number().int().min(1000).default(30000) + .describe('How often to perform health checks, in milliseconds'), /** - * Timeout for health check in milliseconds + * Maximum time to wait for a health check response, in milliseconds. + * + * Renamed from `timeout` (#17780, ruling A on #15939 executing #14478): + * the unit lived in this JSDoc only and the published `.describe()` named + * none. Tombstoned rather than deleted because this object is not + * `.strict()`. */ - timeout: z.number().int().min(100).default(5000) - .describe('Maximum time to wait for health check response'), + timeoutMs: z.number().int().min(100).default(5000) + .describe('Maximum time to wait for health check response, in milliseconds'), /** * Number of consecutive failures before marking as unhealthy @@ -152,6 +193,10 @@ export const PluginHealthCheckSchema = lazySchema(() => z.object({ * REMOVED in 18 (#12032) — the delay before a restart that never happened. */ restartBackoff: retiredKey(RESTART_BACKOFF_RETIRED), + + /** Tombstones for the two duration renames above (#17780, ruling A on #15939). */ + interval: retiredKey(HEALTH_CHECK_INTERVAL_RETIRED), + timeout: retiredKey(HEALTH_CHECK_TIMEOUT_RETIRED), })); const UPTIME_RETIRED = @@ -285,6 +330,22 @@ const HOT_RELOAD_WATCH_PATTERNS_RETIRED = + 'matches — the debounced integration point this class does implement, and ' + 'which is unchanged.'; +/** + * Prescription for the debounce duration renamed in 17 (#17780, ruling A on + * #15939 executing #14478). Carries NO `os migrate meta --from 17` sentence, + * for the reason `HOT_RELOAD_WATCH_PATTERNS_RETIRED` above records: + * `HotReloadConfig` is not an authorable surface, so no authored document has + * ever been able to carry this key. + */ +const HOT_RELOAD_DEBOUNCE_DELAY_RETIRED = + '`HotReloadConfig.debounceDelay` was renamed to `debounceDelayMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the ' + + 'key name, not only in the describe prose. Its unit (milliseconds) lived ' + + 'in a source JSDoc only and the published describe named none, so the ' + + 'reference-page reader got a bare 1000. Rename the key to ' + + '`debounceDelayMs`; the value (milliseconds) and the 1000 default are ' + + 'unchanged.'; + /** * Hot Reload Configuration * Controls how plugins handle live updates @@ -308,10 +369,18 @@ export const HotReloadConfigSchema = lazySchema(() => z.object({ watchPatterns: retiredKey(HOT_RELOAD_WATCH_PATTERNS_RETIRED), /** - * Debounce delay before reloading (milliseconds) + * Debounce delay before reloading, in milliseconds. + * + * Renamed from `debounceDelay` (#17780, ruling A on #15939 executing + * #14478): the unit lived in this JSDoc only and the published + * `.describe()` named none. Tombstoned rather than deleted because this + * object is not `.strict()`. */ - debounceDelay: z.number().int().min(0).default(1000) - .describe('Wait time after change detection before reload'), + debounceDelayMs: z.number().int().min(0).default(1000) + .describe('Wait time after change detection before reload, in milliseconds'), + + /** Tombstone for the rename above (#17780, ruling A on #15939). */ + debounceDelay: retiredKey(HOT_RELOAD_DEBOUNCE_DELAY_RETIRED), /** * Preserve plugin state during reload diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__HotReloadConfig__debounceDelay.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__HotReloadConfig__debounceDelay.ts new file mode 100644 index 0000000000..428ae25a87 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__HotReloadConfig__debounceDelay.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15939 ruling A (per-file remediation of #14478). +// `HotReloadConfig.debounceDelay` said "Debounce delay before reloading +// (milliseconds)" in a source JSDoc and "Wait time after change detection before +// reload" in the `.describe()` the reference pages publish, so the published +// channel named no unit at all and the reference-page reader got a bare 1000. +// Renamed to `debounceDelayMs`, the plain suffix rather than a shortened form: +// this is the only debounce-shaped key spelling in the repo (5 key-position +// occurrences, all this key and its fixtures; no `debounceMs` variant anywhere), +// while the Delay-plus-Ms pairing is already attested (`maxDelayMs`, +// `initialDelayMs`, `retryDelayMs`, `delayMs`) — so there was no competing family +// spelling to choose between. The value and the 1000 default are unchanged. +// Tombstoned with `retiredKey()`: `HotReloadConfigSchema` is not `.strict()`, so +// a bare deletion would silently strip the key and hand `setTimeout` no delay. +// No D2 conversion: not a stack collection member, not a stored row — +// `HotReloadConfig` is a library parameter a host passes to `HotReloadManager` in +// TypeScript, the same reading `hot-reload-watch-placeholder-retired` recorded +// for this def. See `kernel-health-check-and-hot-reload-durations-unit-in-key`. +export const entry = 'kernel/HotReloadConfig:debounceDelay'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthCheck__interval.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthCheck__interval.ts new file mode 100644 index 0000000000..3fde076eb8 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthCheck__interval.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15939 ruling A (per-file remediation of #14478). `PluginHealthCheck.interval` +// said "Health check interval in milliseconds" in a source JSDoc, and the +// `.describe()` the reference pages publish said "How often to perform health +// checks (default: 30s)" — its one unit-shaped token naming SECONDS for a value +// the schema bounds at min 1000 and defaults to 30000 MILLISECONDS. Measured by +// the gate's own census, the key read [name: -] [prose: -]: no unit in the name, +// and none the gate recognises in the prose either. Renamed to `intervalMs` — +// the family's own spelling on this tree (100 key-position `*Ms` declarations in +// packages/spec, `intervalMs` 3 of them). The value and the 30000 default are +// unchanged. Tombstoned with `retiredKey()`: `PluginHealthCheckSchema` is not +// `.strict()`, so a bare deletion would silently strip the key and hand +// `setInterval` no period at all. No D2 conversion: not a stack collection +// member, not a stored row — `PluginHealthCheck` is a library parameter a host +// passes to `PluginHealthMonitor` in TypeScript, the same reading +// `plugin-auto-restart-never-reinitialised` recorded for this def. See +// `kernel-health-check-and-hot-reload-durations-unit-in-key`. +export const entry = 'kernel/PluginHealthCheck:interval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthCheck__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthCheck__timeout.ts new file mode 100644 index 0000000000..05cfa56377 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthCheck__timeout.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15939 ruling A (per-file remediation of #14478). `PluginHealthCheck.timeout` +// said "Timeout for health check in milliseconds" in a source JSDoc and +// "Maximum time to wait for health check response" in the `.describe()` the +// reference pages publish, so the published channel named no unit at all and the +// reference-page reader got a bare 5000. Renamed to `timeoutMs` — the family's +// most attested spelling on this tree (29 key-position `timeoutMs` declarations +// in packages/spec). The value and the 5000 default are unchanged. Tombstoned +// with `retiredKey()`: `PluginHealthCheckSchema` is not `.strict()`, so a bare +// deletion would silently strip the key and race the health check against no +// deadline. No D2 conversion: not a stack collection member, not a stored row — +// `PluginHealthCheck` is a library parameter a host passes to +// `PluginHealthMonitor` in TypeScript, the same reading +// `plugin-auto-restart-never-reinitialised` recorded for this def. See +// `kernel-health-check-and-hot-reload-durations-unit-in-key`. +export const entry = 'kernel/PluginHealthCheck:timeout'; diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-health-check-and-hot-reload-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-health-check-and-hot-reload-durations-unit-in-key.ts new file mode 100644 index 0000000000..954f668f65 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-health-check-and-hot-reload-durations-unit-in-key.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'kernel-health-check-and-hot-reload-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the three plugin-lifecycle durations whose unit lived in a source JSDoc only: ' + + 'PluginHealthCheck.interval, PluginHealthCheck.timeout and HotReloadConfig.debounceDelay ' + + '(kernel/plugin-lifecycle-advanced.zod.ts)', + replacement: 'intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values ' + + '(milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged', + reason: + 'Director-seat ruling A on #15939, 2026-09-11, carrying the maintainer\'s 「同意」 (decision ' + + 'batch #115), executing the #14478 rule per file. Each key named milliseconds in its JSDoc ' + + '— "Health check interval in milliseconds", "Timeout for health check in milliseconds", ' + + '"Debounce delay before reloading (milliseconds)" — and the JSDoc above a key is NOT what ' + + '`content/docs/references/**` renders; `.describe()` is. Measured on this tree by the ' + + 'gate\'s own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] ' + + '— no unit in the name and none in the published prose either. `interval` is the sharpest ' + + 'of the three: its describe carried one unit-shaped token, the parenthetical ' + + '"(default: 30s)", which names SECONDS for a value the schema bounds and defaults in ' + + 'MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, ' + + 'published to the one reader who cannot see the source. The suffix is the family\'s own ' + + 'spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, ' + + 'timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already ' + + 'uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only ' + + 'debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this ' + + 'one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing ' + + 'is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the ' + + 'Ttl-versus-TTL question the sibling round had to settle, there is no competing family ' + + 'spelling to choose between. All three old spellings are retiredKey() tombstones: neither ' + + 'PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would ' + + 'be a SILENT STRIP (#3733, ADR-0104) — and here the stripped value lands on a setInterval ' + + 'period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 ' + + 'conversion: the conversion chain walks a normalized STACK, and neither def is an ' + + 'authorable surface — no metadata-type binding, stack collection or manifest embed carries ' + + 'either, and both are library parameters a host passes to PluginHealthMonitor / ' + + 'HotReloadManager in TypeScript (the #4914 / #11825 keep) — so a conversion would be a ' + + 'transform with no seam that ever runs. That is the same disposition ' + + 'plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded ' + + 'for keys on these two defs. The registration-time refusals in ' + + 'PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for ' + + 'the audience that does not parse. Measured on 884e8347d: the only in-repo readers are ' + + 'packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in ' + + 'this same change; and the pinned objectui checkout — the pin this repo builds ' + + 'against, `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694` — names ' + + 'neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and ' + + 'the string debounceDelay each occur 0 times across its 6409 tracked files, against lit ' + + 'controls objectstack 10171 and @objectstack/spec 3479 on the same corpus.', + acceptanceCriteria: + 'Every producer and reader of a PluginHealthCheck spells intervalMs and timeoutMs, and every ' + + 'one of a HotReloadConfig spells debounceDelayMs — concretely ' + + 'packages/core/src/health-monitor.ts, whose loop now reads setInterval(..., ' + + 'config.intervalMs) and whose race reads config.timeoutMs, and ' + + 'packages/core/src/hot-reload.ts, whose debounce now reads config.debounceDelayMs. ' + + 'Authoring any old spelling fails to compile (input type `never`) and fails to parse with ' + + 'the rename prescription naming the suffixed key; handing one to registerPlugin on either ' + + 'class is refused with an ADR-0112 VALIDATION_ERROR / 400 before the plugin is stored. ' + + 'Behaviour is unchanged: the same milliseconds, the same 30000 / 5000 / 1000 defaults and ' + + 'the same min bounds (1000 / 100 / 0), and the published describes now name milliseconds. ' + + 'The sibling shutdownTimeout on HotReloadConfig is deliberately NOT renamed with them: its ' + + 'JSDoc reads "Graceful shutdown timeout" and names no unit anywhere, so it is the #14519 ' + + 'unit-nowhere shape the #14478 gate leaves outside its verdict, not part of this row set.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4ab46c7fb5..3bb435118d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8553,6 +8553,68 @@ const step18: MigrationStep = { + 'covering that in kernel/events.test.ts was moved onto the new spelling rather than ' + 'dropped.', }, + { + id: 'kernel-health-check-and-hot-reload-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the three plugin-lifecycle durations whose unit lived in a source JSDoc only: ' + + 'PluginHealthCheck.interval, PluginHealthCheck.timeout and HotReloadConfig.debounceDelay ' + + '(kernel/plugin-lifecycle-advanced.zod.ts)', + replacement: 'intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values ' + + '(milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged', + reason: + 'Director-seat ruling A on #15939, 2026-09-11, carrying the maintainer\'s 「同意」 (decision ' + + 'batch #115), executing the #14478 rule per file. Each key named milliseconds in its JSDoc ' + + '— "Health check interval in milliseconds", "Timeout for health check in milliseconds", ' + + '"Debounce delay before reloading (milliseconds)" — and the JSDoc above a key is NOT what ' + + '`content/docs/references/**` renders; `.describe()` is. Measured on this tree by the ' + + 'gate\'s own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] ' + + '— no unit in the name and none in the published prose either. `interval` is the sharpest ' + + 'of the three: its describe carried one unit-shaped token, the parenthetical ' + + '"(default: 30s)", which names SECONDS for a value the schema bounds and defaults in ' + + 'MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, ' + + 'published to the one reader who cannot see the source. The suffix is the family\'s own ' + + 'spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, ' + + 'timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already ' + + 'uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only ' + + 'debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this ' + + 'one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing ' + + 'is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the ' + + 'Ttl-versus-TTL question the sibling round had to settle, there is no competing family ' + + 'spelling to choose between. All three old spellings are retiredKey() tombstones: neither ' + + 'PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would ' + + 'be a SILENT STRIP (#3733, ADR-0104) — and here the stripped value lands on a setInterval ' + + 'period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 ' + + 'conversion: the conversion chain walks a normalized STACK, and neither def is an ' + + 'authorable surface — no metadata-type binding, stack collection or manifest embed carries ' + + 'either, and both are library parameters a host passes to PluginHealthMonitor / ' + + 'HotReloadManager in TypeScript (the #4914 / #11825 keep) — so a conversion would be a ' + + 'transform with no seam that ever runs. That is the same disposition ' + + 'plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded ' + + 'for keys on these two defs. The registration-time refusals in ' + + 'PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for ' + + 'the audience that does not parse. Measured on 884e8347d: the only in-repo readers are ' + + 'packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in ' + + 'this same change; and the pinned objectui checkout — the pin this repo builds ' + + 'against, `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694` — names ' + + 'neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and ' + + 'the string debounceDelay each occur 0 times across its 6409 tracked files, against lit ' + + 'controls objectstack 10171 and @objectstack/spec 3479 on the same corpus.', + acceptanceCriteria: + 'Every producer and reader of a PluginHealthCheck spells intervalMs and timeoutMs, and every ' + + 'one of a HotReloadConfig spells debounceDelayMs — concretely ' + + 'packages/core/src/health-monitor.ts, whose loop now reads setInterval(..., ' + + 'config.intervalMs) and whose race reads config.timeoutMs, and ' + + 'packages/core/src/hot-reload.ts, whose debounce now reads config.debounceDelayMs. ' + + 'Authoring any old spelling fails to compile (input type `never`) and fails to parse with ' + + 'the rename prescription naming the suffixed key; handing one to registerPlugin on either ' + + 'class is refused with an ADR-0112 VALIDATION_ERROR / 400 before the plugin is stored. ' + + 'Behaviour is unchanged: the same milliseconds, the same 30000 / 5000 / 1000 defaults and ' + + 'the same min bounds (1000 / 100 / 0), and the published describes now name milliseconds. ' + + 'The sibling shutdownTimeout on HotReloadConfig is deliberately NOT renamed with them: its ' + + 'JSDoc reads "Graceful shutdown timeout" and names no unit anywhere, so it is the #14519 ' + + 'unit-nowhere shape the #14478 gate leaves outside its verdict, not part of this row set.', + }, { id: 'kernel-package-lifecycle-durations-unit-in-key', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a @@ -12015,6 +12077,24 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // records: a health report is emitted by the startup orchestrator at runtime, // never authored into a metadata document. 'kernel/HealthStatus:timestamp', + // #15939 ruling A (per-file remediation of #14478). + // `HotReloadConfig.debounceDelay` said "Debounce delay before reloading + // (milliseconds)" in a source JSDoc and "Wait time after change detection before + // reload" in the `.describe()` the reference pages publish, so the published + // channel named no unit at all and the reference-page reader got a bare 1000. + // Renamed to `debounceDelayMs`, the plain suffix rather than a shortened form: + // this is the only debounce-shaped key spelling in the repo (5 key-position + // occurrences, all this key and its fixtures; no `debounceMs` variant anywhere), + // while the Delay-plus-Ms pairing is already attested (`maxDelayMs`, + // `initialDelayMs`, `retryDelayMs`, `delayMs`) — so there was no competing family + // spelling to choose between. The value and the 1000 default are unchanged. + // Tombstoned with `retiredKey()`: `HotReloadConfigSchema` is not `.strict()`, so + // a bare deletion would silently strip the key and hand `setTimeout` no delay. + // No D2 conversion: not a stack collection member, not a stored row — + // `HotReloadConfig` is a library parameter a host passes to `HotReloadManager` in + // TypeScript, the same reading `hot-reload-watch-placeholder-retired` recorded + // for this def. See `kernel-health-check-and-hot-reload-durations-unit-in-key`. + 'kernel/HotReloadConfig:debounceDelay', // #12428 — ADR-0049 enforce-or-remove, one symbol over from #12340 (PR #12425) // in the same file and on the same per-key test. `HotReloadManager.startWatching` // contained NO watcher: a guard plus `logger.info('File watching started', @@ -12566,6 +12646,23 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // registration-time refusal in `PluginHealthMonitor.registerPlugin` is the door // for the audience that exists. 'kernel/PluginHealthCheck:autoRestart', + // #15939 ruling A (per-file remediation of #14478). `PluginHealthCheck.interval` + // said "Health check interval in milliseconds" in a source JSDoc, and the + // `.describe()` the reference pages publish said "How often to perform health + // checks (default: 30s)" — its one unit-shaped token naming SECONDS for a value + // the schema bounds at min 1000 and defaults to 30000 MILLISECONDS. Measured by + // the gate's own census, the key read [name: -] [prose: -]: no unit in the name, + // and none the gate recognises in the prose either. Renamed to `intervalMs` — + // the family's own spelling on this tree (100 key-position `*Ms` declarations in + // packages/spec, `intervalMs` 3 of them). The value and the 30000 default are + // unchanged. Tombstoned with `retiredKey()`: `PluginHealthCheckSchema` is not + // `.strict()`, so a bare deletion would silently strip the key and hand + // `setInterval` no period at all. No D2 conversion: not a stack collection + // member, not a stored row — `PluginHealthCheck` is a library parameter a host + // passes to `PluginHealthMonitor` in TypeScript, the same reading + // `plugin-auto-restart-never-reinitialised` recorded for this def. See + // `kernel-health-check-and-hot-reload-durations-unit-in-key`. + 'kernel/PluginHealthCheck:interval', // #12032 — ADR-0049 enforce-or-remove, one class over from #12428 (PR #12571) // and #12340 (PR #12425) in the same host-driven lifecycle library, and for a // sharper reason than either: this key HAD a reader that acted, and what it did @@ -12672,6 +12769,21 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // registration-time refusal in `PluginHealthMonitor.registerPlugin` is the door // for the audience that exists. 'kernel/PluginHealthCheck:restartBackoff', + // #15939 ruling A (per-file remediation of #14478). `PluginHealthCheck.timeout` + // said "Timeout for health check in milliseconds" in a source JSDoc and + // "Maximum time to wait for health check response" in the `.describe()` the + // reference pages publish, so the published channel named no unit at all and the + // reference-page reader got a bare 5000. Renamed to `timeoutMs` — the family's + // most attested spelling on this tree (29 key-position `timeoutMs` declarations + // in packages/spec). The value and the 5000 default are unchanged. Tombstoned + // with `retiredKey()`: `PluginHealthCheckSchema` is not `.strict()`, so a bare + // deletion would silently strip the key and race the health check against no + // deadline. No D2 conversion: not a stack collection member, not a stored row — + // `PluginHealthCheck` is a library parameter a host passes to + // `PluginHealthMonitor` in TypeScript, the same reading + // `plugin-auto-restart-never-reinitialised` recorded for this def. See + // `kernel-health-check-and-hot-reload-durations-unit-in-key`. + 'kernel/PluginHealthCheck:timeout', // #15678 (stack card 3/6 of #14478) — ruling B. `PluginHealthReport.metrics.responseTime` // said "Average response time in ms" in prose and nothing else. Renamed to // `responseTimeMs`; the value is unchanged. Tombstoned with `retiredKey()`.