Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .changeset/17780-plugin-lifecycle-duration-units.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered kernel-health-check-and-hot-reload-durations-unit-in-key -->

**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
14 changes: 8 additions & 6 deletions content/docs/protocol/kernel/lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions content/docs/references/kernel/plugin-lifecycle-advanced.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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. |


---
Expand Down
Loading
Loading