From 66801ae148a0f952771008550aeec3388b16ca20 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:27:36 +0000 Subject: [PATCH 1/5] wip: retire the startup orchestrator surface, re-declare the result Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- packages/core/src/plugin-loader.ts | 46 +-- .../contracts/startup-orchestrator.test.ts | 253 ++++---------- .../src/contracts/startup-orchestrator.ts | 100 ++---- .../startup-orchestrator-retirement.test.ts | 129 +++++++ .../src/kernel/startup-orchestrator.test.ts | 295 +++++----------- .../src/kernel/startup-orchestrator.zod.ts | 330 +++++++----------- .../retired-defs/18.kernel__HealthStatus.ts | 13 + .../retired-defs/18.kernel__StartupOptions.ts | 13 + .../18.kernel__StartupOrchestrationResult.ts | 10 + .../18.kernel__PluginStartupResult__health.ts | 10 + .../18.kernel__PluginStartupResult__plugin.ts | 11 + .../18.startup-orchestrator-retired.ts | 72 ++++ .../src/type-alias-convention.pin.test.ts | 27 +- 13 files changed, 604 insertions(+), 705 deletions(-) create mode 100644 packages/spec/src/kernel/startup-orchestrator-retirement.test.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.kernel__HealthStatus.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.kernel__StartupOptions.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.kernel__StartupOrchestrationResult.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__health.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__plugin.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.startup-orchestrator-retired.ts diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index dfe0ddff2df..4f232f01c81 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -88,35 +88,25 @@ export interface PluginLoadResult { /** * Plugin Startup Result + * + * [#16059] DECLARED BY THE SPEC, re-exported here. + * + * This interface used to be hand-written in this file, and its twin lived in + * `packages/spec/src/kernel/startup-orchestrator.zod.ts` describing a different + * shape (a `plugin: { name, version }` object, a required `durationMs`, a + * `health` member for a probe system that does not exist). The maintainer ruling + * on #16059 (director seat, decision batch #60, 2026-09-06) re-declared the + * SCHEMA against what this package actually ships and made the type flow one + * way — spec to core — so the two cannot drift apart again. + * + * The shape is unchanged for every producer and reader here: `pluginName`, + * `success`, optional `durationMs`, the deprecated `startTime` alias, + * `timedOut`, and `error` — declared by the spec as the serializable projection + * (`name` / `message` / `stack` / `code`) that a thrown `Error` satisfies + * structurally, so `ObjectKernel` keeps handing the live instance through and + * `instanceof Error` keeps narrowing at the read site. */ -export interface PluginStartupResult { - success: boolean; - pluginName: string; - /** - * Elapsed milliseconds the plugin's `start()` took. - * - * Named for the member `packages/spec` declares for the same measure -- - * `PluginStartupResultSchema.durationMs` in - * `packages/spec/src/kernel/startup-orchestrator.zod.ts` ("Time taken to - * start the plugin in milliseconds"), where the bare `duration` spelling is - * retired: a duration-shaped number carries its unit in its key name. Like - * `PluginLoadResult.loadTime` above, it is the same `Date.now() - startTime` - * computation under a name that does not lie. - */ - durationMs?: number; - /** - * The same elapsed milliseconds as {@link PluginStartupResult.durationMs}. - * - * @deprecated Misnamed: this has never held an instant, so a reader who - * correctly takes `startTime` for one and writes `Date.now() - result.startTime` - * gets an age near the epoch instead of a wait. Read `durationMs` instead. - * Still populated so nothing has to change on this release (ADR-0087 L1 -- - * the old shape keeps working while the fleet moves); slated for removal. - */ - startTime?: number; - error?: Error; - timedOut?: boolean; -} +export type { PluginStartupResult } from '@objectstack/spec/kernel'; /** * Version Compatibility Result diff --git a/packages/spec/src/contracts/startup-orchestrator.test.ts b/packages/spec/src/contracts/startup-orchestrator.test.ts index 9971963a605..db9f919baf4 100644 --- a/packages/spec/src/contracts/startup-orchestrator.test.ts +++ b/packages/spec/src/contracts/startup-orchestrator.test.ts @@ -1,202 +1,75 @@ import { describe, it, expect } from 'vitest'; -import type { - StartupOptions, - StartupOptionsParsed, - PluginStartupResult, - HealthStatus, - IStartupOrchestrator, -} from './startup-orchestrator'; -// [#4538] The contract's data shapes ARE the kernel zod types — the same +import type { PluginStartupResult } from './startup-orchestrator'; +// [#4538] The contract's data shape IS the kernel zod type — the same // declaration, re-exported. This import compiling is part of the pin. -import { StartupOptionsSchema } from '../kernel/startup-orchestrator.zod'; -import type { Plugin } from './plugin-validator'; - -describe('Startup Orchestrator Contract', () => { - describe('StartupOptions tiers (re-exported kernel zod types, #4538)', () => { - it('should allow an empty INPUT-tier options object (all optional)', () => { - const options: StartupOptions = {}; - - expect(options).toBeDefined(); - expect(options.timeoutMs).toBeUndefined(); - expect(options.rollbackOnFailure).toBeUndefined(); - }); - - it('parses the input tier into the defaulted StartupOptions tier', () => { - const parsed: StartupOptionsParsed = StartupOptionsSchema.parse({}); - - expect(parsed.timeoutMs).toBe(30000); - expect(parsed.rollbackOnFailure).toBe(true); - expect(parsed.healthCheck).toBe(false); - expect(parsed.parallel).toBe(false); - }); - - it('should allow full options', () => { - const options: StartupOptions = { - timeoutMs: 30000, - rollbackOnFailure: true, - healthCheck: true, - parallel: false, - context: { db: 'postgres' }, - }; - - expect(options.timeoutMs).toBe(30000); - expect(options.rollbackOnFailure).toBe(true); - expect(options.healthCheck).toBe(true); - expect(options.parallel).toBe(false); - expect(options.context).toEqual({ db: 'postgres' }); - }); +import { PluginStartupResultSchema } from '../kernel/startup-orchestrator.zod'; + +// [#16059] `IStartupOrchestrator` and the three data schemas it tied together +// (`StartupOptions`, `HealthStatus`, `StartupOrchestrationResult`) are retired +// under ADR-0049 — nothing implemented the interface, nothing parsed the +// schemas, and `checkHealth` was the interface of a probe system that does not +// exist. Their absence is pinned in +// `../kernel/startup-orchestrator-retirement.test.ts`; what this file still +// covers is the one shape that survived, on the entry that re-exports it. +describe('Startup Orchestrator Contract — the surviving result shape', () => { + it('types a successful startup with the plugin NAME', () => { + const result: PluginStartupResult = { + pluginName: 'auth-plugin', + success: true, + durationMs: 150, + }; + + expect(result.success).toBe(true); + expect(result.pluginName).toBe('auth-plugin'); + expect(result.durationMs).toBe(150); + expect(result.error).toBeUndefined(); }); - describe('HealthStatus interface', () => { - it('should allow a minimal health status', () => { - const status: HealthStatus = { - healthy: true, - checkedAt: Date.now(), - }; + it('types the no-start() path, where durationMs is absent', () => { + const result: PluginStartupResult = { + pluginName: 'inert-plugin', + success: true, + }; - expect(status.healthy).toBe(true); - expect(status.checkedAt).toBeGreaterThan(0); - }); - - it('should allow a full health status with details', () => { - const status: HealthStatus = { - healthy: false, - checkedAt: Date.now(), - details: { connections: 0, maxConnections: 10 }, - message: 'No database connections available', - }; - - expect(status.healthy).toBe(false); - expect(status.message).toBe('No database connections available'); - expect(status.details).toHaveProperty('connections'); - }); + expect(result.durationMs).toBeUndefined(); }); - describe('PluginStartupResult interface', () => { - it('should represent a successful startup', () => { - const plugin: Plugin = { name: 'auth-plugin', version: '1.0.0' }; - const result: PluginStartupResult = { - plugin, - success: true, - durationMs: 150, - }; - - expect(result.success).toBe(true); - expect(result.durationMs).toBe(150); - expect(result.error).toBeUndefined(); - }); - - it('should represent a failed startup with a SERIALIZABLE error (#4538)', () => { - const plugin: Plugin = { name: 'broken-plugin' }; - const result: PluginStartupResult = { - plugin, - success: false, - durationMs: 30000, - // The kernel schema declares the serializable projection, not a live - // Error instance — what a wire/log consumer of the result can carry. - error: { name: 'Error', message: 'Timeout' }, - }; - - expect(result.success).toBe(false); - expect(result.error!.message).toBe('Timeout'); - }); - - it('should include optional health status', () => { - const plugin: Plugin = { name: 'healthy-plugin' }; - const result: PluginStartupResult = { - plugin, - success: true, - durationMs: 50, - health: { - healthy: true, - checkedAt: Date.now(), - details: { uptime: 1000 }, - }, - }; - - expect(result.health!.healthy).toBe(true); - }); + it('types a timed-out startup with a SERIALIZABLE error (#4538)', () => { + const result: PluginStartupResult = { + pluginName: 'broken-plugin', + success: false, + durationMs: 30000, + // The kernel schema declares the serializable projection — what a + // wire/log consumer of the result can carry. + error: { name: 'Error', message: 'Timeout' }, + timedOut: true, + }; + + expect(result.success).toBe(false); + expect(result.error!.message).toBe('Timeout'); + expect(result.timedOut).toBe(true); }); - describe('IStartupOrchestrator interface', () => { - it('should allow a minimal implementation', () => { - const orchestrator: IStartupOrchestrator = { - orchestrateStartup: async (plugins, _options) => { - return plugins.map((p) => ({ - plugin: p, - success: true, - durationMs: 10, - })); - }, - rollback: async (_startedPlugins) => {}, - checkHealth: async (_plugin) => ({ - healthy: true, - checkedAt: Date.now(), - }), - }; - - expect(typeof orchestrator.orchestrateStartup).toBe('function'); - expect(typeof orchestrator.rollback).toBe('function'); - expect(typeof orchestrator.checkHealth).toBe('function'); - }); - - it('should orchestrate startup for multiple plugins', async () => { - const plugins: Plugin[] = [ - { name: 'core', version: '1.0.0' }, - { name: 'auth', version: '2.0.0', dependencies: ['core'] }, - ]; - - const orchestrator: IStartupOrchestrator = { - orchestrateStartup: async (pluginList, options) => { - return pluginList.map((p) => ({ - plugin: p, - success: true, - durationMs: options.timeoutMs ? 10 : 20, - })); - }, - rollback: async () => {}, - checkHealth: async () => ({ healthy: true, checkedAt: Date.now() }), - }; - - const results = await orchestrator.orchestrateStartup(plugins, { timeoutMs: 5000 }); - expect(results).toHaveLength(2); - expect(results[0].success).toBe(true); - expect(results[1].plugin.name).toBe('auth'); - }); - - it('should support optional startWithTimeout method', async () => { - const orchestrator: IStartupOrchestrator = { - orchestrateStartup: async () => [], - rollback: async () => {}, - checkHealth: async () => ({ healthy: true, checkedAt: Date.now() }), - startWithTimeout: async (_plugin, _context, _timeoutMs) => {}, - }; - - expect(orchestrator.startWithTimeout).toBeDefined(); - await expect( - orchestrator.startWithTimeout!({ name: 'test' }, {}, 5000) - ).resolves.toBeUndefined(); - }); - - it('should rollback started plugins on failure', async () => { - const rolledBack: string[] = []; - - const orchestrator: IStartupOrchestrator = { - orchestrateStartup: async () => [], - rollback: async (startedPlugins) => { - for (const p of startedPlugins) { - rolledBack.push(p.name); - } - }, - checkHealth: async () => ({ healthy: true, checkedAt: Date.now() }), - }; - - await orchestrator.rollback([ - { name: 'plugin-a' }, - { name: 'plugin-b' }, - ]); + it('a live Error satisfies the declared projection — what the kernel hands through', () => { + // `ObjectKernel.startPluginWithTimeout()` puts the thrown instance in + // `error` so the boot loop can rethrow it as the new error's `cause`. That + // is legal against this contract because `Error` IS a + // `{ name, message, stack? }` — the projection is what a consumer may + // rely on, not a narrowing of what the kernel may pass. + const thrown = new Error('Connection failed'); + const result: PluginStartupResult = { + pluginName: 'db-plugin', + success: false, + error: thrown, + }; + + expect(result.error instanceof Error).toBe(true); + expect(result.error!.name).toBe('Error'); + }); - expect(rolledBack).toEqual(['plugin-a', 'plugin-b']); - }); + it('is the SAME declaration the kernel entry exports (#4538)', () => { + const value: PluginStartupResult = { pluginName: 'x', success: true }; + const parsed = PluginStartupResultSchema.safeParse(value); + expect(parsed.success).toBe(true); }); }); diff --git a/packages/spec/src/contracts/startup-orchestrator.ts b/packages/spec/src/contracts/startup-orchestrator.ts index a4f1413b0fa..123154a0303 100644 --- a/packages/spec/src/contracts/startup-orchestrator.ts +++ b/packages/spec/src/contracts/startup-orchestrator.ts @@ -1,79 +1,25 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin } from './plugin-validator.js'; -// [#4538] The startup-orchestration data shapes are the kernel domain's -// zod-derived types — one declaration per name (#4446), re-exported here so -// the contract surface and the schema surface can never drift again. The -// hand-written twins this file used to carry had already drifted twice: -// `error` was a live `Error` where the schema declares the serializable -// projection, and `StartupOptions` conflated the caller-authored (input) -// tier with the parsed tier. -import type { - HealthStatus, - PluginStartupResult, - StartupOptions, -} from '../kernel/startup-orchestrator.zod'; -export type { - HealthStatus, - PluginStartupResult, - StartupOptions, - StartupOptionsParsed, -} from '../kernel/startup-orchestrator.zod'; - -/** - * IStartupOrchestrator - Startup Orchestrator Interface - * - * Abstract interface for orchestrating plugin startup with advanced features: - * - Timeout handling - * - Rollback on failure - * - Health checks - * - Startup metrics - * - * Extracted from PluginLoader to follow Single Responsibility Principle. - */ - -/** - * IStartupOrchestrator - Plugin startup orchestration interface - */ -export interface IStartupOrchestrator { - /** - * Orchestrate startup of multiple plugins - * Handles timeout, rollback, and health checks - * @param plugins - Array of plugins to start (in dependency order) - * @param options - Startup options — the caller-authored (input) tier; - * implementations apply `StartupOptionsSchema` defaults - * @returns Promise resolving to startup results for each plugin - */ - orchestrateStartup( - plugins: Plugin[], - options: StartupOptions - ): Promise; - - /** - * Rollback (destroy) a set of plugins - * Used when startup fails and rollback is enabled - * @param startedPlugins - Plugins that were successfully started - * @returns Promise that resolves when rollback is complete - */ - rollback(startedPlugins: Plugin[]): Promise; - - /** - * Check health of a single plugin - * @param plugin - Plugin to check - * @returns Promise resolving to health status - */ - checkHealth(plugin: Plugin): Promise; - - /** - * Wait for a plugin to start with timeout - * @param plugin - Plugin to start - * @param context - Plugin context - * @param timeoutMs - Maximum time to wait (milliseconds) - * @returns Promise resolving when plugin starts or rejecting on timeout - */ - startWithTimeout?( - plugin: Plugin, - context: any, - timeoutMs: number - ): Promise; -} +// [#4538] The startup-result data shape is the kernel domain's zod-derived type +// — one declaration per name (#4446), re-exported here so the contract surface +// and the schema surface can never drift. +// +// ── [#16059] The orchestrator contract itself is RETIRED ──────────────────── +// +// `IStartupOrchestrator` — `orchestrateStartup(plugins, options)`, `rollback`, +// `checkHealth`, `startWithTimeout` — is removed under ADR-0049 +// enforce-or-remove (maintainer ruling, director seat decision batch #60, +// 2026-09-06). Nothing in any repository implemented it and nothing called it: +// plugin startup is the kernel's own boot loop (`ObjectKernel.start()` → +// `startPluginWithTimeout()`), which races each plugin's `start()` against its +// `startupTimeout`, rolls back on failure, and never consults a health probe — +// there is no probe system for `checkHealth` to be the interface of. The three +// data schemas the interface tied together (`StartupOptions`, `HealthStatus`, +// `StartupOrchestrationResult`) leave with it; the full record, the measurement +// and the route live in the retirement block in +// `../kernel/startup-orchestrator.zod.ts`. +// +// What survives is the one shape the kernel really produces, still re-exported +// here so a consumer that imports its contracts from `@objectstack/spec/contracts` +// keeps the name it already had. +export type { PluginStartupResult } from '../kernel/startup-orchestrator.zod'; diff --git a/packages/spec/src/kernel/startup-orchestrator-retirement.test.ts b/packages/spec/src/kernel/startup-orchestrator-retirement.test.ts new file mode 100644 index 00000000000..2a07be2dbb2 --- /dev/null +++ b/packages/spec/src/kernel/startup-orchestrator-retirement.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { + EXPORT_ENTRY_POINTS, + exportNamesOf, + holdersOf, +} from '../../scripts/lib/export-origins-testkit'; + +// ─── [#16059] The startup ORCHESTRATION surface is RETIRED ────────────────── +// +// ADR-0049 enforce-or-remove; maintainer ruling, director seat decision batch +// #60, 2026-09-06. `kernel/startup-orchestrator.zod.ts` no longer declares +// `StartupOptionsSchema` (with its `healthCheck`), `HealthStatusSchema` or +// `StartupOrchestrationResultSchema`, and `contracts/startup-orchestrator.ts` +// no longer declares `IStartupOrchestrator` — 3 emitted defs, 8 exported +// names, the reference page's sections with them. +// +// The measurement that decided it, re-run on this card against this +// retirement's base commit with lit same-corpus controls: +// +// 1. IMPLEMENTERS — nobody. No class or object literal in any repository +// implements `IStartupOrchestrator`, and nothing calls +// `orchestrateStartup`, `rollback`, `checkHealth` or `startWithTimeout` +// on one. Plugin startup is `ObjectKernel.start()` → +// `startPluginWithTimeout()`, which reads its timeout from +// `PluginMetadata.startupTimeout` and its rollback policy from +// `KernelConfig.rollbackOnFailure`. +// 2. PARSERS — nobody. No `.parse` / `.safeParse` of the three schemas +// outside this module's own tests. +// 3. CROSS-REPO — zero hits for all six names in the pinned `objectui` +// checkout, controls `defineStack` (27 files) and `ManifestSchema` +// lighting up on the same corpus. Every remaining hit in this repository +// was a generated artifact or a released `CHANGELOG.md`. +// +// `healthCheck` and `HealthStatus` are the sharpest of the four: they name a +// per-plugin startup health probe the runtime has never had — the #3950 shape +// an AI author (ADR-0033) reads as proof the capability exists. +// +// Route 3: with no authored document carrying the defs there is no seam for a +// D2 conversion and no author to tombstone for — `RETIRED_DEFS_BY_MAJOR[18]` +// plus the D3 semantic entry `startup-orchestrator-retired` ARE the +// declaration. Form follows #11825 / #8715 / #4988: resolved symbol identity +// over every public entry via the build-time `export-origins/` artifact. +describe('[#16059] startup orchestrator retirement', () => { + /** The 8 names the retired defs and the retired interface exported. */ + const RETIRED_NAMES = [ + 'StartupOptionsSchema', + 'StartupOptions', + 'StartupOptionsParsed', + 'HealthStatusSchema', + 'HealthStatus', + 'StartupOrchestrationResultSchema', + 'StartupOrchestrationResult', + 'IStartupOrchestrator', + ] as const; + + /** + * What must SURVIVE, and where. The ruling keeps a startup-result contract + * describing what the kernel actually produces, so a too-wide "tidy the + * startup module" sweep that took these with the rest would be the failure + * this list exists to catch — and `PluginStartupResult` staying on + * `./contracts` is what keeps a consumer importing from that entry whole. + */ + const MUST_SURVIVE_KERNEL = ['PluginStartupResultSchema', 'PluginStartupResult'] as const; + const MUST_SURVIVE_CONTRACTS = ['PluginStartupResult'] as const; + + it('every retired name has ZERO holders on any public entry; the survivors still stand', () => { + // Anti-vacuity: the baseline must cover the real surface. + for (const needed of ['.', './kernel', './contracts']) { + expect(EXPORT_ENTRY_POINTS, `exports map must include ${needed}`).toContain(needed); + } + expect( + exportNamesOf('./kernel').length, + './kernel must export a non-trivial surface' + ).toBeGreaterThan(50); + expect( + exportNamesOf('./contracts').length, + './contracts must export a non-trivial surface' + ).toBeGreaterThan(50); + + // ── ABSENCE (every entry, not just the two that declared them) ───────── + for (const name of RETIRED_NAMES) { + expect(holdersOf(name), `${name} must have zero holders after #16059`).toEqual([]); + } + + // ── SURVIVAL ────────────────────────────────────────────────────────── + const kernelNames = exportNamesOf('./kernel'); + for (const name of MUST_SURVIVE_KERNEL) { + expect(kernelNames, `${name} must SURVIVE this retirement on ./kernel`).toContain(name); + } + const contractNames = exportNamesOf('./contracts'); + for (const name of MUST_SURVIVE_CONTRACTS) { + expect(contractNames, `${name} must SURVIVE this retirement on ./contracts`).toContain(name); + } + }); + + it('the runtime barrels resolve without the retired names and keep the survivor', async () => { + const kernel = await import('./index'); + for (const name of [ + 'StartupOptionsSchema', + 'HealthStatusSchema', + 'StartupOrchestrationResultSchema', + ]) { + expect(kernel, `${name} must not be exported after #16059`).not.toHaveProperty(name); + } + // Anti-vacuity: the barrel really resolved, and the kept contract stands. + expect(kernel).toHaveProperty('PluginStartupResultSchema'); + expect(kernel).toHaveProperty('ManifestSchema'); + }); + + it('the surviving schema declares the shape @objectstack/core ships', async () => { + // The point of the ruling: the spec's result and the kernel's result are + // ONE declaration. If a later edit narrows this schema away from what + // `startPluginWithTimeout()` returns, core stops compiling — but this + // asserts the shape here too, so the reason is named at the spec end. + const { PluginStartupResultSchema } = await import('./startup-orchestrator.zod'); + const shape = (PluginStartupResultSchema as unknown as { shape: Record }).shape; + for (const member of ['pluginName', 'success', 'durationMs', 'startTime', 'error', 'timedOut']) { + expect(Object.keys(shape), `${member} must be declared`).toContain(member); + } + // The two members that left the surviving def are tombstones, not + // deletions — present in the shape, refusing with a prescription. + for (const member of ['plugin', 'health', 'duration']) { + expect(Object.keys(shape), `${member} must remain as a tombstone`).toContain(member); + } + }); +}); diff --git a/packages/spec/src/kernel/startup-orchestrator.test.ts b/packages/spec/src/kernel/startup-orchestrator.test.ts index d1f64ba1015..e56df89abfe 100644 --- a/packages/spec/src/kernel/startup-orchestrator.test.ts +++ b/packages/spec/src/kernel/startup-orchestrator.test.ts @@ -1,204 +1,88 @@ import { describe, it, expect } from 'vitest'; -import { - StartupOptionsSchema, - HealthStatusSchema, - PluginStartupResultSchema, - StartupOrchestrationResultSchema, -} from './startup-orchestrator.zod'; - -describe('Startup Orchestrator Protocol', () => { - describe('StartupOptionsSchema', () => { - it('should apply default values', () => { - const options = {}; - - const result = StartupOptionsSchema.parse(options); - expect(result.timeoutMs).toBe(30000); - expect(result.rollbackOnFailure).toBe(true); - expect(result.healthCheck).toBe(false); - expect(result.parallel).toBe(false); - }); - - it('should validate custom options', () => { - const options = { - timeoutMs: 60000, - rollbackOnFailure: false, - healthCheck: true, - parallel: true, - context: { custom: 'data' }, - }; - - const result = StartupOptionsSchema.safeParse(options); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.timeoutMs).toBe(60000); - expect(result.data.context).toEqual({ custom: 'data' }); - } - }); - - it('should reject negative timeout', () => { - const options = { - timeoutMs: -1000, - }; - - const result = StartupOptionsSchema.safeParse(options); - expect(result.success).toBe(false); +import { PluginStartupResultSchema } from './startup-orchestrator.zod'; + +// [#16059] The orchestration vocabulary this file used to cover +// (`StartupOptionsSchema`, `HealthStatusSchema`, +// `StartupOrchestrationResultSchema`) is retired — see +// `startup-orchestrator-retirement.test.ts` for the absence pins and the zod +// module's retirement block for the record. What is left is the one shape the +// kernel really produces, so the cases below are written against +// `ObjectKernel.startPluginWithTimeout()`'s three real return paths. +describe('PluginStartupResultSchema — the shape the kernel produces', () => { + it('accepts the no-start() path: success with no elapsed time', () => { + const result = PluginStartupResultSchema.safeParse({ + pluginName: 'crm-plugin', + success: true, }); + expect(result.success).toBe(true); }); - describe('HealthStatusSchema', () => { - it('should validate healthy status', () => { - const healthyStatus = { - healthy: true, - checkedAt: Date.now(), - details: { - databaseConnected: true, - memoryUsage: 45.2, - }, - }; - - const result = HealthStatusSchema.safeParse(healthyStatus); - expect(result.success).toBe(true); - }); - - it('should validate unhealthy status with message', () => { - const unhealthyStatus = { - healthy: false, - checkedAt: Date.now(), - message: 'Database connection failed', - }; - - const result = HealthStatusSchema.safeParse(unhealthyStatus); - expect(result.success).toBe(true); + it('accepts the success path: durationMs plus the deprecated startTime alias', () => { + const result = PluginStartupResultSchema.safeParse({ + pluginName: 'crm-plugin', + success: true, + durationMs: 1250, + startTime: 1250, }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.durationMs).toBe(1250); + // The kernel populates both with the SAME elapsed value under its + // ADR-0087 L1 disposition; the contract mirrors that rather than + // declaring `never` for a member the kernel emits. + expect(result.data.startTime).toBe(result.data.durationMs); + } }); - describe('PluginStartupResultSchema', () => { - it('should validate successful startup result', () => { - const successResult = { - plugin: { - name: 'crm-plugin', - version: '1.0.0', - }, - success: true, - durationMs: 1250, - }; - - const result = PluginStartupResultSchema.safeParse(successResult); - expect(result.success).toBe(true); - }); - - it('should validate failed startup result with error', () => { - const failedResult = { - plugin: { - name: 'failing-plugin', - version: '1.0.0', - }, - success: false, - durationMs: 500, - error: { name: 'Error', message: 'Connection failed' }, - }; - - const result = PluginStartupResultSchema.safeParse(failedResult); - expect(result.success).toBe(true); - }); - - it('should validate result with health status', () => { - const resultWithHealth = { - plugin: { - name: 'crm-plugin', - }, - success: true, - durationMs: 1250, - health: { - healthy: true, - checkedAt: Date.now(), - }, - }; - - const result = PluginStartupResultSchema.safeParse(resultWithHealth); - expect(result.success).toBe(true); - }); - - it('should reject negative duration', () => { - const invalidResult = { - plugin: { name: 'test' }, - success: true, - durationMs: -100, - }; - - const result = PluginStartupResultSchema.safeParse(invalidResult); - expect(result.success).toBe(false); + it('accepts the failure path: the serializable error projection', () => { + const result = PluginStartupResultSchema.safeParse({ + pluginName: 'failing-plugin', + success: false, + durationMs: 500, + error: { name: 'Error', message: 'Connection failed' }, }); + expect(result.success).toBe(true); }); - describe('StartupOrchestrationResultSchema', () => { - it('should validate complete orchestration result', () => { - const orchestrationResult = { - results: [ - { - plugin: { name: 'plugin1', version: '1.0.0' }, - success: true, - durationMs: 1200, - }, - { - plugin: { name: 'plugin2', version: '2.0.0' }, - success: true, - durationMs: 850, - }, - ], - totalDurationMs: 2050, - allSuccessful: true, - }; - - const result = StartupOrchestrationResultSchema.safeParse(orchestrationResult); - expect(result.success).toBe(true); + it('accepts the TIMEOUT failure path, where timedOut is set', () => { + const result = PluginStartupResultSchema.safeParse({ + pluginName: 'slow-plugin', + success: false, + durationMs: 30000, + error: { name: 'Error', message: 'Plugin slow-plugin start timeout after 30000ms' }, + timedOut: true, }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.timedOut).toBe(true); + }); - it('should validate orchestration with rollback', () => { - const orchestrationWithRollback = { - results: [ - { - plugin: { name: 'plugin1' }, - success: true, - durationMs: 1200, - }, - { - plugin: { name: 'plugin2' }, - success: false, - durationMs: 850, - error: { name: 'Error', message: 'Startup failed' }, - }, - ], - totalDurationMs: 2050, - allSuccessful: false, - rolledBack: ['plugin1'], - }; + it('requires pluginName — the result carries the NAME, never a plugin object', () => { + const result = PluginStartupResultSchema.safeParse({ success: true }); + expect(result.success).toBe(false); + }); - const result = StartupOrchestrationResultSchema.safeParse(orchestrationWithRollback); - expect(result.success).toBe(true); + it('rejects a negative duration', () => { + const result = PluginStartupResultSchema.safeParse({ + pluginName: 'test', + success: true, + durationMs: -100, }); + expect(result.success).toBe(false); }); }); // #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped -// number lives in the key NAME. All three old spellings are `retiredKey()` -// tombstones, so the refusal carries the RENAME (the prescription IS the -// payload) rather than a bare unrecognized-key error. This contract already -// contained its own counter-example: `startWithTimeout(plugin, ctx, timeoutMs)` -// named its parameter correctly while the options object beside it did not. -describe('Startup orchestration durations carry their unit (#15678)', () => { - it('StartupOptions REFUSES the retired `timeout` with the rename in the message', () => { - const result = StartupOptionsSchema.safeParse({ timeout: 60000 }); - expect(result.success).toBe(false); - const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); - expect(issue).toBeDefined(); - expect(issue!.code).not.toBe('unrecognized_keys'); - expect(issue!.message).toContain('`StartupOptions.timeout` was renamed to `timeoutMs`'); - }); - - it('PluginStartupResult REFUSES the retired `duration` with the rename in the message', () => { +// number lives in the key NAME. The old spelling is a `retiredKey()` tombstone, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error. The two sibling tombstones this file used +// to cover (`StartupOptions.timeout`, `StartupOrchestrationResult.totalDuration`) +// left with their defs on #16059 — a whole-def removal is strictly stronger +// than "this one key is gone", and the retired-key registrations that dated +// them stay in RETIRED_KEYS_BY_MAJOR[18] as the record of the narrower step. +describe('Startup result durations carry their unit (#15678)', () => { + it('REFUSES the retired `duration` with the rename in the message', () => { const result = PluginStartupResultSchema.safeParse({ - plugin: { name: 'crm-plugin' }, + pluginName: 'crm-plugin', success: true, duration: 1250, }); @@ -208,37 +92,36 @@ describe('Startup orchestration durations carry their unit (#15678)', () => { expect(issue!.code).not.toBe('unrecognized_keys'); expect(issue!.message).toContain('`PluginStartupResult.duration` was renamed to `durationMs`'); }); +}); - it('StartupOrchestrationResult REFUSES the retired `totalDuration` with the rename', () => { - const result = StartupOrchestrationResultSchema.safeParse({ - results: [], - totalDuration: 2050, - allSuccessful: true, +// [#16059] The two members that left the SURVIVING def. Both are tombstones, +// not deletions, because this def keeps emitting and `@objectstack/core` +// imports its type: a construction site still writing them meets the +// prescription at the parse as well as at `tsc`. +describe('[#16059] the re-declared result refuses the members it dropped', () => { + it('REFUSES `plugin` and prescribes `pluginName`', () => { + const result = PluginStartupResultSchema.safeParse({ + pluginName: 'crm-plugin', + success: true, + plugin: { name: 'crm-plugin', version: '1.0.0' }, }); expect(result.success).toBe(false); - const issue = result.error!.issues.find((i) => i.path.join('.') === 'totalDuration'); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'plugin'); expect(issue).toBeDefined(); expect(issue!.code).not.toBe('unrecognized_keys'); - expect(issue!.message).toContain( - '`StartupOrchestrationResult.totalDuration` was renamed to `totalDurationMs`', - ); + expect(issue!.message).toContain('Replace the key with `pluginName`'); }); - it('the aggregate and its parts now agree: totalDurationMs sums durationMs', () => { - const parsed = StartupOrchestrationResultSchema.parse({ - results: [ - { plugin: { name: 'plugin1' }, success: true, durationMs: 1200 }, - { plugin: { name: 'plugin2' }, success: true, durationMs: 850 }, - ], - totalDurationMs: 2050, - allSuccessful: true, + it('REFUSES `health` and says no startup probe system exists', () => { + const result = PluginStartupResultSchema.safeParse({ + pluginName: 'crm-plugin', + success: true, + health: { healthy: true, checkedAt: Date.now() }, }); - expect(parsed.totalDurationMs).toBe(2050); - expect(parsed.results.reduce((sum, r) => sum + r.durationMs, 0)).toBe(2050); - }); - - it('keeps the 30000 default under the renamed key', () => { - expect(StartupOptionsSchema.parse({}).timeoutMs).toBe(30000); - expect(StartupOptionsSchema.parse({ timeoutMs: 5000 }).timeoutMs).toBe(5000); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'health'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/health.*removed.*Delete the key/s); }); }); diff --git a/packages/spec/src/kernel/startup-orchestrator.zod.ts b/packages/spec/src/kernel/startup-orchestrator.zod.ts index 3516956699a..2d2ae18d9a7 100644 --- a/packages/spec/src/kernel/startup-orchestrator.zod.ts +++ b/packages/spec/src/kernel/startup-orchestrator.zod.ts @@ -2,183 +2,140 @@ import { z } from 'zod'; -/** - * Startup Orchestrator Protocol - * - * Zod schemas for plugin startup orchestration data structures. - * These schemas align with the IStartupOrchestrator contract interface. - * - * Following ObjectStack "Zod First" principle - all data structures - * must have Zod schemas for runtime validation and JSON Schema generation. - */ - -// ============================================================================ -// Startup Configuration Schemas -// ============================================================================ - -/** - * Startup Options Schema - * Configuration for plugin startup orchestration - * - * @example - * { - * "timeoutMs": 30000, - * "rollbackOnFailure": true, - * "healthCheck": false, - * "parallel": false - * } - */ import { lazySchema } from '../shared/lazy-schema'; -import { EpochMs } from '../shared/epoch.zod'; import { retiredKey } from '../shared/retired-key'; -export const StartupOptionsSchema = lazySchema(() => z.object({ - /** - * Maximum time (ms) to wait for each plugin to start - * @default 30000 (30 seconds) - */ - // Renamed from `timeout` (#15678, #14478 ruling B): the unit lived only in the - // describe prose. The contract's own `startWithTimeout(plugin, ctx, timeoutMs)` - // parameter already spelled it this way. - timeoutMs: z.number().int().min(0).optional().default(30000) - .describe('Maximum time in milliseconds to wait for each plugin to start'), - - /** Tombstone for the rename above (#15678, ruling B on #14478). */ - timeout: retiredKey( - '`StartupOptions.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. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', - ), - - /** - * Whether to rollback (destroy) already-started plugins on failure - * @default true - */ - rollbackOnFailure: z.boolean().optional().default(true) - .describe('Whether to rollback already-started plugins if any plugin fails'), - - /** - * Whether to run health checks after startup - * @default false - */ - healthCheck: z.boolean().optional().default(false) - .describe('Whether to run health checks after plugin startup'), - - /** - * Whether to run plugins in parallel (if dependencies allow) - * @default false (sequential startup) - */ - parallel: z.boolean().optional().default(false) - .describe('Whether to start plugins in parallel when dependencies allow'), - - /** - * Custom context to pass to plugin lifecycle methods - */ - context: z.unknown().optional().describe('Custom context object to pass to plugin lifecycle methods'), -})); - -export type StartupOptions = z.input; -/** Post-parse shape of {@link StartupOptions} — defaults applied, transforms run (ADR-0122). */ -export type StartupOptionsParsed = z.infer; - -// ============================================================================ -// Health Status Schemas -// ============================================================================ /** - * Health Status Schema - * Health status for a plugin - * - * @example - * { - * "healthy": true, - * "checkedAt": 1706659200000, - * "details": { - * "databaseConnected": true, - * "memoryUsage": 45.2 - * } - * } + * Plugin Startup Result Protocol + * + * One schema, describing the one startup datum the kernel actually produces. + * + * `ObjectKernel.startPluginWithTimeout()` (`packages/core/src/kernel.ts`) + * races a plugin's `start()` against its `startupTimeout` and returns exactly + * this record for every plugin it starts — on the success path, on the failure + * path, and for a plugin that declares no `start()` at all. The kernel's boot + * loop is its only caller and its only reader: a `success: false` result is + * logged, and with `rollbackOnFailure` set the already-started plugins are + * destroyed and the original `error` is rethrown as the new error's `cause`. + * + * What each member means, and when it is present: + * + * - `pluginName` — always. The plugin's registered name; the result carries the + * NAME, never a plugin object. + * - `success` — always. `false` means `start()` threw or the timeout fired. + * - `durationMs` — `Date.now()` elapsed across the `start()` race, on BOTH the + * success and the failure path. Absent for a plugin with no `start()`, which + * returns `{ success: true, pluginName }` without racing anything. + * - `error` — the failure path only: the value `start()` threw. Declared here + * as the serializable projection every consumer of this record can carry + * (`name` / `message` / `stack` / `code`), which a thrown `Error` satisfies — + * the kernel hands the live instance through, so `instanceof Error` still + * narrows at the read site and the original cause survives the rethrow. + * - `timedOut` — the failure path only, and only when the failure was the + * TIMEOUT rather than a throw from inside `start()`: the kernel sets it from + * the raced rejection's message. Absent on the success path; absent, not + * `false`, when a plugin's own `start()` threw. + * - `startTime` — deprecated; see its own note below. + * + * ── [#16059] What this module used to declare, and why it no longer does ──── + * + * Until this major it also declared a whole startup-ORCHESTRATION vocabulary — + * `StartupOptionsSchema` (with `timeoutMs`, `rollbackOnFailure`, `healthCheck`, + * `parallel`, `context`), `HealthStatusSchema`, `StartupOrchestrationResultSchema` + * — beside the `IStartupOrchestrator` contract interface in + * `contracts/startup-orchestrator.ts` that tied them together + * (`orchestrateStartup` / `rollback` / `checkHealth` / `startWithTimeout`). + * + * Maintainer ruling on #16059 (director seat, decision batch #60, 2026-09-06): + * the spec KEEPS a startup-result contract and it describes what the kernel + * actually produces. Neither enforcing the never-landed design nor dropping the + * contract was adopted. So the orchestrator vocabulary is retired under ADR-0049 + * enforce-or-remove and this result schema is re-declared against the shipped + * shape, with `@objectstack/core` importing the type from here rather than + * declaring a twin — the drift that made the two disagree cannot recur. + * + * Nothing implemented `IStartupOrchestrator` and nothing parsed the three + * schemas: measured across this repository and the pinned `objectui` checkout + * with lit same-corpus controls, every reference outside this module's own + * tests was a generated artifact or a released `CHANGELOG.md`. `healthCheck` + * and `HealthStatus` in particular named a probe system that does not exist — + * the kernel never checks a plugin's health at startup — which is the #3950 + * shape: a published vocabulary an author (ADR-0033) reads as proof of a + * capability, that parses clean and is received by nobody. + * + * Route 3 of the retirement playbook: with no authored document carrying the + * defs there is no seam for a D2 conversion and nobody to hand a tombstone to, + * so `RETIRED_DEFS_BY_MAJOR[18]` plus the D3 semantic entry + * `startup-orchestrator-retired` ARE the declaration. Two keys of THIS + * surviving schema are tombstoned rather than dropped, because this def keeps + * emitting and its type is imported by `@objectstack/core`: a construction site + * still writing them gets the prescription through `tsc`. + * + * Following ObjectStack "Zod First" principle — all data structures have Zod + * schemas for runtime validation and JSON Schema generation. */ -export const HealthStatusSchema = lazySchema(() => z.object({ - /** - * Whether the plugin is healthy - */ - healthy: z.boolean().describe('Whether the plugin is healthy'), - - /** - * Health check timestamp (Unix milliseconds) - */ - // Renamed from `timestamp` and typed `EpochMs` (#15676, #14478 ruling B): the - // instant the health check ran, named for what it marks. - checkedAt: EpochMs.describe('Unix timestamp in milliseconds when health check was performed'), - - /** Tombstone for the rename above (#15676, ruling B on #14478). */ - timestamp: retiredKey( - '`HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 — the ' - + 'instant the check RAN now carries the shared `EpochMs` schema, which declares the ' - + 'epoch-millisecond unit the bare key name left to the describe prose. Rename the key ' - + 'to `checkedAt`; the value is unchanged (`Date.now()`).', - ), - - /** - * Optional health details (plugin-specific) - */ - details: z.record(z.string(), z.unknown()).optional().describe('Optional plugin-specific health details'), - - /** - * Optional error message if unhealthy - */ - message: z.string().optional().describe('Error message if plugin is unhealthy'), -})); - -export type HealthStatus = z.input; // ============================================================================ -// Startup Result Schemas +// Plugin Startup Result Schema // ============================================================================ /** * Plugin Startup Result Schema - * Result of a single plugin startup operation - * + * The per-plugin outcome the kernel returns for every plugin it starts + * * @example * { - * "plugin": { "name": "crm-plugin", "version": "1.0.0" }, + * "pluginName": "crm-plugin", * "success": true, - * "durationMs": 1250, - * "health": { - * "healthy": true, - * "checkedAt": 1706659200000 - * } + * "durationMs": 1250 + * } + * + * @example + * { + * "pluginName": "slow-plugin", + * "success": false, + * "durationMs": 30000, + * "error": { "name": "Error", "message": "Plugin slow-plugin start timeout after 30000ms" }, + * "timedOut": true * } */ export const PluginStartupResultSchema = lazySchema(() => z.object({ /** - * Plugin that was started + * Name of the plugin that was started */ - plugin: z.object({ - name: z.string(), - version: z.string().optional(), - }).passthrough().describe('Plugin metadata'), - + pluginName: z.string().describe('Name of the plugin that was started'), + /** * Whether startup was successful */ success: z.boolean().describe('Whether the plugin started successfully'), - + /** * Time taken to start (milliseconds) + * + * Optional because a plugin that declares no `start()` is resolved without + * racing anything, and there is no elapsed time to report. */ // Renamed from `duration` (#15678, #14478 ruling B): the unit lived only in the // describe prose. - durationMs: z.number().min(0).describe('Time taken to start the plugin in milliseconds'), + durationMs: z.number().min(0).optional() + .describe('Time taken to start the plugin in milliseconds; absent when the plugin declares no start()'), + + /** + * The same elapsed milliseconds as `durationMs`. + * + * @deprecated Misnamed: it has never held an instant, so a reader who + * correctly takes `startTime` for one and computes `Date.now() - startTime` + * gets an age near the epoch instead of a wait. Read `durationMs` instead. + * Mirrored here because `@objectstack/core` still POPULATES it under its + * ADR-0087 L1 disposition — the old shape keeps working while the fleet + * moves — and a contract that declared `never` for a member the kernel emits + * would be the very drift #16059 closed. It leaves this schema in the same + * change that stops the kernel emitting it. + */ + startTime: z.number().min(0).optional() + .describe('[DEPRECATED] The same elapsed milliseconds as durationMs — read durationMs instead'), - /** Tombstone for the rename above (#15678, ruling B on #14478). */ - duration: retiredKey( - '`PluginStartupResult.duration` was renamed to `durationMs` 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 `durationMs`; the value (milliseconds) is unchanged.', - ), - /** * Error if startup failed */ @@ -188,62 +145,37 @@ export const PluginStartupResultSchema = lazySchema(() => z.object({ stack: z.string().optional().describe('Stack trace'), code: z.string().optional().describe('Error code'), }).optional().describe('Serializable error representation if startup failed'), - - /** - * Health status after startup (if healthCheck enabled) - */ - health: HealthStatusSchema.optional().describe('Health status after startup if health check was enabled'), -})); - -export type PluginStartupResult = z.input; - -// ============================================================================ -// Startup Orchestration Result Schema -// ============================================================================ -/** - * Startup Orchestration Result Schema - * Overall result of orchestrating startup for multiple plugins - * - * @example - * { - * "results": [ - * { "plugin": { "name": "plugin1" }, "success": true, "durationMs": 1200 }, - * { "plugin": { "name": "plugin2" }, "success": true, "durationMs": 850 } - * ], - * "totalDurationMs": 2050, - * "allSuccessful": true - * } - */ -export const StartupOrchestrationResultSchema = lazySchema(() => z.object({ - /** - * Individual plugin startup results - */ - results: z.array(PluginStartupResultSchema).describe('Startup results for each plugin'), - /** - * Total time taken for all plugins (milliseconds) + * Whether the failure was the startup TIMEOUT rather than a throw from + * inside the plugin's own `start()` */ - // Renamed from `totalDuration` (#15678, #14478 ruling B): the unit lived only - // in the describe prose. - totalDurationMs: z.number().min(0).describe('Total time taken for all plugins in milliseconds'), + timedOut: z.boolean().optional() + .describe('Whether startup failed because the startup timeout fired, rather than start() throwing'), - /** Tombstone for the rename above (#15678, ruling B on #14478). */ - totalDuration: retiredKey( - '`StartupOrchestrationResult.totalDuration` was renamed to `totalDurationMs` in @objectstack/spec 17 — ' + /** Tombstone for the `duration` → `durationMs` rename (#15678, ruling B on #14478). */ + duration: retiredKey( + '`PluginStartupResult.duration` was renamed to `durationMs` 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 `totalDurationMs`; the value (milliseconds) is unchanged.', + + 'in the describe prose. Rename the key to `durationMs`; the value (milliseconds) is unchanged.', + ), + + /** Tombstone for the `plugin` → `pluginName` re-declaration (#16059). */ + plugin: retiredKey( + '`PluginStartupResult.plugin` was removed in @objectstack/spec 18 (ADR-0049) — ' + + 'the kernel has never put a plugin OBJECT in this result, so the nested ' + + '`{ name, version }` shape described a value nothing ever built. Replace the key ' + + 'with `pluginName` and carry the plugin name string.', + ), + + /** Tombstone for the health member, retired with `HealthStatus` itself (#16059). */ + health: retiredKey( + '`PluginStartupResult.health` was removed in @objectstack/spec 18 (ADR-0049) — ' + + 'it carried a `HealthStatus`, and that vocabulary is retired with the startup ' + + 'orchestrator that declared it: no probe system ever ran a health check at ' + + 'startup, so nothing ever filled the key. Delete the key; a plugin that reports ' + + 'health does it through a service it registers, not through this result.', ), - - /** - * Whether all plugins started successfully - */ - allSuccessful: z.boolean().describe('Whether all plugins started successfully'), - - /** - * Plugins that were rolled back (if rollbackOnFailure was enabled) - */ - rolledBack: z.array(z.string()).optional().describe('Names of plugins that were rolled back'), })); -export type StartupOrchestrationResult = z.input; +export type PluginStartupResult = z.input; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.kernel__HealthStatus.ts b/packages/spec/src/migrations/entries/retired-defs/18.kernel__HealthStatus.ts new file mode 100644 index 00000000000..b395e14608a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.kernel__HealthStatus.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16059 — `HealthStatusSchema` (`healthy`, `checkedAt`, `details`, `message`) +// was the return vocabulary of `IStartupOrchestrator.checkHealth(plugin)` and +// the value of `PluginStartupResult.health`. Both carriers left in this same +// major, and no probe system was ever built behind either: the kernel does not +// check a plugin's health at startup, so nothing ever produced a HealthStatus. +// An exported health vocabulary with no producer reads as proof the platform +// health-checks plugins (#3950, ADR-0033). The `health` member of the surviving +// `PluginStartupResult` is tombstoned rather than dropped, because that def +// keeps emitting — see `18.kernel__PluginStartupResult__health.ts`. Route 3; +// the D3 semantic entry `startup-orchestrator-retired` carries the record. +export const entry = 'kernel/HealthStatus'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.kernel__StartupOptions.ts b/packages/spec/src/migrations/entries/retired-defs/18.kernel__StartupOptions.ts new file mode 100644 index 00000000000..7d7cc7f9be9 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.kernel__StartupOptions.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16059 — `StartupOptionsSchema` (`timeoutMs`, `rollbackOnFailure`, +// `healthCheck`, `parallel`, `context`) left with the `IStartupOrchestrator` +// contract it was the argument of: it was only ever the `options` parameter of +// `orchestrateStartup(plugins, options)`, and nothing in any repository +// implemented or called that method. The kernel's own boot loop reads its +// timeout from `PluginMetadata.startupTimeout` and its rollback policy from +// `KernelConfig.rollbackOnFailure`, never from this object. `healthCheck` named +// a startup probe system that does not exist. Route 3 (no authored document +// carries the def, so no tombstone and no D2 conversion): this table plus the +// D3 semantic entry `startup-orchestrator-retired` ARE the declaration. +export const entry = 'kernel/StartupOptions'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.kernel__StartupOrchestrationResult.ts b/packages/spec/src/migrations/entries/retired-defs/18.kernel__StartupOrchestrationResult.ts new file mode 100644 index 00000000000..cfc44b3257a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.kernel__StartupOrchestrationResult.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16059 — `StartupOrchestrationResultSchema` (`results`, `totalDurationMs`, +// `allSuccessful`, `rolledBack`) was the aggregate `orchestrateStartup()` +// returned, and it left with that method: no implementation ever existed, so no +// aggregate was ever built. The kernel starts plugins one at a time and returns +// a `PluginStartupResult` per plugin; the per-plugin durations it does keep are +// reachable through `ObjectKernel.getPluginStartupDurations()`. Route 3; the D3 +// semantic entry `startup-orchestrator-retired` carries the record. +export const entry = 'kernel/StartupOrchestrationResult'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__health.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__health.ts new file mode 100644 index 00000000000..33a6e211081 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__health.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16059 — `PluginStartupResult.health` held the `HealthStatus` a startup +// health check would have produced. The health-check option, the orchestrator +// interface that declared the check and the `HealthStatus` vocabulary itself +// all leave in this major (`kernel/HealthStatus` in RETIRED_DEFS_BY_MAJOR[18]), +// and nothing ever filled the key. Tombstoned rather than dropped because the +// carrying def survives — see `18.kernel__PluginStartupResult__plugin.ts` for +// why that matters here. +export const entry = 'kernel/PluginStartupResult:health'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__plugin.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__plugin.ts new file mode 100644 index 00000000000..ca38c996288 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__plugin.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16059 — `PluginStartupResult.plugin` carried a nested +// `{ name, version } & Record` plugin object. The kernel has +// never built one: `ObjectKernel.startPluginWithTimeout()` has always returned +// the plugin NAME, and the re-declaration of this schema against the shipped +// shape replaces the key with `pluginName`. Tombstoned rather than dropped +// because this def keeps emitting and its type is imported by +// `@objectstack/core`, so a construction site still writing `plugin` meets the +// prescription through tsc as well as through a parse. +export const entry = 'kernel/PluginStartupResult:plugin'; diff --git a/packages/spec/src/migrations/entries/semantic/18.startup-orchestrator-retired.ts b/packages/spec/src/migrations/entries/semantic/18.startup-orchestrator-retired.ts new file mode 100644 index 00000000000..b7b9d02a6af --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.startup-orchestrator-retired.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'startup-orchestrator-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the startup-ORCHESTRATION surface of kernel/startup-orchestrator.zod.ts and ' + + 'contracts/startup-orchestrator.ts — 3 emitted defs and 8 exported names: ' + + 'StartupOptionsSchema / StartupOptions / StartupOptionsParsed, ' + + 'HealthStatusSchema / HealthStatus, StartupOrchestrationResultSchema / ' + + 'StartupOrchestrationResult, and the IStartupOrchestrator interface ' + + '(orchestrateStartup / rollback / checkHealth / startWithTimeout). The ' + + 'startup RESULT survives, re-declared: PluginStartupResultSchema and ' + + 'PluginStartupResult stay on both entries', + replacement: + '(removed — there is no declarative replacement, because nothing ever ' + + 'implemented the interface or parsed the schemas. Plugin startup is the ' + + 'kernel own boot loop: ObjectKernel.start() calls startPluginWithTimeout() ' + + 'per plugin, which races that plugin start() against ' + + 'PluginMetadata.startupTimeout and, when KernelConfig.rollbackOnFailure is ' + + 'set, destroys the already-started plugins and rethrows the original error ' + + 'as the new error cause. So: instead of StartupOptions.timeoutMs declare ' + + 'startupTimeout on the plugin; instead of StartupOptions.rollbackOnFailure ' + + 'set rollbackOnFailure on the kernel config; instead of ' + + 'StartupOrchestrationResult.results read the per-plugin durations through ' + + 'ObjectKernel.getPluginStartupDurations(). StartupOptions.healthCheck and ' + + 'HealthStatus have NO replacement at all — no startup probe system exists, ' + + 'and one returns only through the enforce route of ADR-0049 with a new ADR, ' + + 'the probe first and the vocabulary second. StartupOptions.parallel and ' + + 'StartupOptions.context likewise: the kernel starts plugins sequentially ' + + 'and passes its own PluginContext)', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling on #16059 (director seat, ' + + 'decision batch #60, 2026-09-06). The module declared an orchestration ' + + 'design that never landed, and the spec and the kernel had already drifted ' + + 'into disagreement about the one shape that did: PluginStartupResultSchema ' + + 'described a plugin object, a required durationMs and a health member, ' + + 'while @objectstack/core shipped pluginName, an optional durationMs and ' + + 'timedOut. The ruling keeps a startup-result contract that describes what ' + + 'the kernel actually produces, and retires the rest. Re-measured on this ' + + 'card: zero implementers and zero consumers of the four retired surfaces in ' + + 'this repository and in the pinned objectui checkout, with lit same-corpus ' + + 'controls (defineStack, ManifestSchema); every remaining reference was a ' + + 'generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus ' + + 'are the sharpest of the four: they name a per-plugin health probe the ' + + 'runtime has never had, which is the #3950 shape an AI author (ADR-0033) ' + + 'reads as proof the capability exists. With no authored document carrying ' + + 'any of the three defs there is no seam for a D2 conversion and no author to ' + + 'tombstone for: route 3, the #4834 / #11825 shape — RETIRED_DEFS_BY_MAJOR ' + + 'plus this entry ARE the declaration. The two keys of the SURVIVING result ' + + 'schema that leave (plugin, health) are tombstoned instead, and registered ' + + 'in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is ' + + 'imported by @objectstack/core.', + acceptanceCriteria: + 'No code imports any of the 8 retired names from @objectstack/spec, ' + + '@objectstack/spec/kernel or @objectstack/spec/contracts — every one is ' + + 'TS2305 after upgrade, pinned by resolved symbol identity in ' + + 'kernel/startup-orchestrator-retirement.test.ts. No metadata document needs ' + + 'editing: none of the three defs was reachable from a metadata-type ' + + 'binding, a stack collection or a manifest embed, so no authored document ' + + 'could ever carry one. PluginStartupResult SURVIVES on both entries with ' + + 'the shape the kernel ships — pluginName, success, optional durationMs, the ' + + 'deprecated startTime alias, the serializable error projection, timedOut — ' + + 'and @objectstack/core now imports that type instead of declaring a twin, ' + + 'so the drift cannot recur. Writing plugin or health on a PluginStartupResult ' + + 'is a tsc error and a parse error carrying the rename or the deletion. ' + + 'Runtime behaviour is deliberately UNCHANGED: nothing ever read the retired ' + + 'surfaces, and the kernel boot loop is untouched.', +}; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index bd40a9f4b57..b8f95f8022d 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -273,7 +273,7 @@ import type * as M185 from './shared/epoch.zod.js'; import type * as M187 from './shared/duration.zod.js'; // --------------------------------------------------------------------------- -// 785 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 783 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -950,9 +950,7 @@ export type Iso465 = Assert, z.infe export type Iso466 = Assert, z.infer< typeof M103.ScopeInfoSchema > >>; // kernel/startup-orchestrator.zod.ts -export type Iso467 = Assert, z.infer< typeof M104.HealthStatusSchema > >>; export type Iso468 = Assert, z.infer< typeof M104.PluginStartupResultSchema > >>; -export type Iso469 = Assert, z.infer< typeof M104.StartupOrchestrationResultSchema > >>; // qa/testing.zod.ts export type Iso470 = Assert, z.infer< typeof M105.TestSuiteSchema > >>; @@ -1666,7 +1664,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 785 isomorphic pins', () => { + it('still declares all 783 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2210,7 +2208,26 @@ describe('ADR-0122 type-alias convention', () => { // that default onto the shared type instead, author state and parsed state // part company for every key in the family at once, and these are the lines // that say so by name. - expect(pins).toHaveLength(785); + // 785 -> 783 is #16059's ADR-0049 retirement of the startup ORCHESTRATION + // surface (kernel/startup-orchestrator.zod.ts, module slot M104): + // `HealthStatusSchema` (`Iso467`) and `StartupOrchestrationResultSchema` + // (`Iso469`) left with their defs (whole-def removal, + // `RETIRED_DEFS_BY_MAJOR[18]` `kernel/HealthStatus` + + // `kernel/StartupOrchestrationResult`), so the pins that named them leave + // with the schemas — there is nothing left to be isomorphic. The module + // slot stays occupied and `Iso468` stays with it: the maintainer ruling + // KEEPS `PluginStartupResultSchema`, re-declared against the shape + // `@objectstack/core` ships, and it is still the (RISE) case — every new + // member is `.optional()` with no default and no transform, so author + // state and parsed state still coincide. -2 removed; the Iso numbers stay + // vacant (ids are claims about pins, not positions). + // + // Worth one line on the member that could have moved it: the deprecated + // `startTime` alias is mirrored, not defaulted. The day someone writes + // `startTime: durationMs`-style `.default()` or a `.transform()` that + // fills one member from another, this pin is the line that says the alias + // has gained a second shape. + expect(pins).toHaveLength(783); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either From a77b7955bc66f9889418368e884ef0372b3449c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:38:39 +0000 Subject: [PATCH 2/5] wip: regenerate spec artifacts for the startup-orchestrator retirement Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- content/docs/references/index.mdx | 10 +- .../kernel/startup-orchestrator.mdx | 158 +++++++++--------- ...07-unknown-key-strictness-ledger.counts.md | 2 +- packages/spec/api-surface/contracts.json | 4 - packages/spec/api-surface/kernel.json | 7 - packages/spec/authorable-defaults/kernel.json | 4 - packages/spec/authorable-surface/kernel.json | 23 +-- packages/spec/declaration-map/kernel.json | 6 - packages/spec/export-origins/contracts.json | 4 - packages/spec/export-origins/kernel.json | 7 - .../spec/json-schema.manifest/kernel.json | 3 - packages/spec/src/migrations/registry.ts | 115 +++++++++++++ 12 files changed, 203 insertions(+), 140 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index cfd5ebc836b..ecb4fb95a3e 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1525 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1522 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -25,7 +25,7 @@ counts are sums of the rows they head. Regenerate with | [Data Protocol](/docs/references/data) | 29 | 173 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 24 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | -| [Kernel Protocol](/docs/references/kernel) | 30 | 162 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | +| [Kernel Protocol](/docs/references/kernel) | 30 | 159 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | | [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 33 | 272 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **193** | **1525** | 14 protocol modules | +| **Total** | **193** | **1522** | 14 protocol modules | --- @@ -196,7 +196,7 @@ The single connector protocol (ADR-0097) — catalog descriptors and provider-bo ## Kernel Protocol -**Source:** `packages/spec/src/kernel/` · **Import:** `@objectstack/spec/kernel` · **30 pages, 162 schemas** +**Source:** `packages/spec/src/kernel/` · **Import:** `@objectstack/spec/kernel` · **30 pages, 159 schemas** Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. @@ -231,7 +231,7 @@ Plugin lifecycle and manifests, capabilities and security, metadata loading, ser | [`plugin-validator.zod.ts`](/docs/references/kernel/plugin-validator) | `PluginMetadata`, `ValidationError`, `ValidationResult`, `ValidationWarning` | | [`plugin-versioning.zod.ts`](/docs/references/kernel/plugin-versioning) | `BreakingChange`, `CompatibilityLevel`, `CompatibilityMatrixEntry`, `DependencyConflict`, `DeprecationNotice`, `MultiVersionSupport`, `PluginCompatibilityMatrix`, `PluginDependencyResolutionResult`, `PluginVersionMetadata`, `SemanticVersion`, `VersionConstraint` | | [`service-registry.zod.ts`](/docs/references/kernel/service-registry) | `ScopeConfig`, `ScopeInfo`, `ServiceFactoryRegistration`, `ServiceMetadata`, `ServiceRegistryConfig`, `ServiceScopeType` | -| [`startup-orchestrator.zod.ts`](/docs/references/kernel/startup-orchestrator) | `HealthStatus`, `PluginStartupResult`, `StartupOptions`, `StartupOrchestrationResult` | +| [`startup-orchestrator.zod.ts`](/docs/references/kernel/startup-orchestrator) | `PluginStartupResult` | --- diff --git a/content/docs/references/kernel/startup-orchestrator.mdx b/content/docs/references/kernel/startup-orchestrator.mdx index ed261a553f6..bebd30a83aa 100644 --- a/content/docs/references/kernel/startup-orchestrator.mdx +++ b/content/docs/references/kernel/startup-orchestrator.mdx @@ -5,13 +5,73 @@ description: Startup Orchestrator protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Startup Orchestrator Protocol - -Zod schemas for plugin startup orchestration data structures. -These schemas align with the IStartupOrchestrator contract interface. - -Following ObjectStack "Zod First" principle - all data structures -must have Zod schemas for runtime validation and JSON Schema generation. +Plugin Startup Result Protocol + +One schema, describing the one startup datum the kernel actually produces. + +`ObjectKernel.startPluginWithTimeout()` (`packages/core/src/kernel.ts`) +races a plugin's `start()` against its `startupTimeout` and returns exactly +this record for every plugin it starts — on the success path, on the failure +path, and for a plugin that declares no `start()` at all. The kernel's boot +loop is its only caller and its only reader: a `success: false` result is +logged, and with `rollbackOnFailure` set the already-started plugins are +destroyed and the original `error` is rethrown as the new error's `cause`. + +What each member means, and when it is present: + +- `pluginName` — always. The plugin's registered name; the result carries the + NAME, never a plugin object. +- `success` — always. `false` means `start()` threw or the timeout fired. +- `durationMs` — `Date.now()` elapsed across the `start()` race, on BOTH the + success and the failure path. Absent for a plugin with no `start()`, which + returns `{ success: true, pluginName }` without racing anything. +- `error` — the failure path only: the value `start()` threw. Declared here + as the serializable projection every consumer of this record can carry + (`name` / `message` / `stack` / `code`), which a thrown `Error` satisfies — + the kernel hands the live instance through, so `instanceof Error` still + narrows at the read site and the original cause survives the rethrow. +- `timedOut` — the failure path only, and only when the failure was the + TIMEOUT rather than a throw from inside `start()`: the kernel sets it from + the raced rejection's message. Absent on the success path; absent, not + `false`, when a plugin's own `start()` threw. +- `startTime` — deprecated; see its own note below. + +── [#16059] What this module used to declare, and why it no longer does ──── + +Until this major it also declared a whole startup-ORCHESTRATION vocabulary — +`StartupOptionsSchema` (with `timeoutMs`, `rollbackOnFailure`, `healthCheck`, +`parallel`, `context`), `HealthStatusSchema`, `StartupOrchestrationResultSchema` +— beside the `IStartupOrchestrator` contract interface in +`contracts/startup-orchestrator.ts` that tied them together +(`orchestrateStartup` / `rollback` / `checkHealth` / `startWithTimeout`). + +Maintainer ruling on #16059 (director seat, decision batch #60, 2026-09-06): +the spec KEEPS a startup-result contract and it describes what the kernel +actually produces. Neither enforcing the never-landed design nor dropping the +contract was adopted. So the orchestrator vocabulary is retired under ADR-0049 +enforce-or-remove and this result schema is re-declared against the shipped +shape, with `@objectstack/core` importing the type from here rather than +declaring a twin — the drift that made the two disagree cannot recur. + +Nothing implemented `IStartupOrchestrator` and nothing parsed the three +schemas: measured across this repository and the pinned `objectui` checkout +with lit same-corpus controls, every reference outside this module's own +tests was a generated artifact or a released `CHANGELOG.md`. `healthCheck` +and `HealthStatus` in particular named a probe system that does not exist — +the kernel never checks a plugin's health at startup — which is the #3950 +shape: a published vocabulary an author (ADR-0033) reads as proof of a +capability, that parses clean and is received by nobody. + +Route 3 of the retirement playbook: with no authored document carrying the +defs there is no seam for a D2 conversion and nobody to hand a tombstone to, +so `RETIRED_DEFS_BY_MAJOR[18]` plus the D3 semantic entry +`startup-orchestrator-retired` ARE the declaration. Two keys of THIS +surviving schema are tombstoned rather than dropped, because this def keeps +emitting and its type is imported by `@objectstack/core`: a construction site +still writing them gets the prescription through `tsc`. + +Following ObjectStack "Zod First" principle — all data structures have Zod +schemas for runtime validation and JSON Schema generation. **Source:** `packages/spec/src/kernel/startup-orchestrator.zod.ts` @@ -20,28 +80,13 @@ must have Zod schemas for runtime validation and JSON Schema generation. ## TypeScript Usage ```typescript -import { HealthStatusSchema, PluginStartupResultSchema, StartupOptionsSchema, StartupOrchestrationResultSchema } from '@objectstack/spec/kernel'; -import type { HealthStatus, PluginStartupResult, StartupOptions, StartupOrchestrationResult } from '@objectstack/spec/kernel'; +import { PluginStartupResultSchema } from '@objectstack/spec/kernel'; +import type { PluginStartupResult } from '@objectstack/spec/kernel'; // Validate data -const result = HealthStatusSchema.parse(data); +const result = PluginStartupResultSchema.parse(data); ``` ---- - -## HealthStatus - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **healthy** | `boolean` | ✅ | Whether the plugin is healthy | -| **checkedAt** | `integer` | ✅ | Unix timestamp in milliseconds when health check was performed | -| **timestamp** | `never` | optional | [REMOVED] `HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 — the instant the check RAN now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `checkedAt`; the value is unchanged (`Date.now()`). | -| **details** | `Record` | optional | Optional plugin-specific health details | -| **message** | `string` | optional | Error message if plugin is unhealthy | - - --- ## PluginStartupResult @@ -50,12 +95,15 @@ const result = HealthStatusSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **plugin** | `{ name: string; version?: string } & Record` | ✅ | Plugin metadata | +| **pluginName** | `string` | ✅ | Name of the plugin that was started | | **success** | `boolean` | ✅ | Whether the plugin started successfully | -| **durationMs** | `number` | ✅ | Time taken to start the plugin in milliseconds | -| **duration** | `never` | optional | [REMOVED] `PluginStartupResult.duration` was renamed to `durationMs` 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 `durationMs`; the value (milliseconds) is unchanged. | +| **durationMs** | `number` | optional | Time taken to start the plugin in milliseconds; absent when the plugin declares no start() | +| **startTime** | `number` | optional | [DEPRECATED] The same elapsed milliseconds as durationMs — read durationMs instead | | **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed | -| **health** | `{ healthy: boolean; checkedAt: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | +| **timedOut** | `boolean` | optional | Whether startup failed because the startup timeout fired, rather than start() throwing | +| **duration** | `never` | optional | [REMOVED] `PluginStartupResult.duration` was renamed to `durationMs` 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 `durationMs`; the value (milliseconds) is unchanged. | +| **plugin** | `never` | optional | [REMOVED] `PluginStartupResult.plugin` was removed in @objectstack/spec 18 (ADR-0049) — the kernel has never put a plugin OBJECT in this result, so the nested `{ name, version }` shape described a value nothing ever built. Replace the key with `pluginName` and carry the plugin name string. | +| **health** | `never` | optional | [REMOVED] `PluginStartupResult.health` was removed in @objectstack/spec 18 (ADR-0049) — it carried a `HealthStatus`, and that vocabulary is retired with the startup orchestrator that declared it: no probe system ever ran a health check at startup, so nothing ever filled the key. Delete the key; a plugin that reports health does it through a service it registers, not through this result. | ### Nested Shape: `PluginStartupResult.error` @@ -66,58 +114,6 @@ const result = HealthStatusSchema.parse(data); | **stack** | `string` | optional | Stack trace | | **code** | `string` | optional | Error code | -### Nested Shape: `PluginStartupResult.health` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **healthy** | `boolean` | ✅ | Whether the plugin is healthy | -| **checkedAt** | `integer` | ✅ | Unix timestamp in milliseconds when health check was performed | -| **timestamp** | `never` | optional | [REMOVED] `HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 — the instant the check RAN now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `checkedAt`; the value is unchanged (`Date.now()`). | -| **details** | `Record` | optional | Optional plugin-specific health details | -| **message** | `string` | optional | Error message if plugin is unhealthy | - - ---- - -## StartupOptions - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **timeoutMs** | `integer` | optional (default: `30000`) | Maximum time in milliseconds to wait for each plugin to start | -| **timeout** | `never` | optional | [REMOVED] `StartupOptions.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. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | -| **rollbackOnFailure** | `boolean` | optional (default: `true`) | Whether to rollback already-started plugins if any plugin fails | -| **healthCheck** | `boolean` | optional (default: `false`) | Whether to run health checks after plugin startup | -| **parallel** | `boolean` | optional (default: `false`) | Whether to start plugins in parallel when dependencies allow | -| **context** | `any` | optional | Custom context object to pass to plugin lifecycle methods | - - ---- - -## StartupOrchestrationResult - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **results** | `{ plugin: object; success: boolean; durationMs: number; error?: object; … }[]` | ✅ | Startup results for each plugin | -| **totalDurationMs** | `number` | ✅ | Total time taken for all plugins in milliseconds | -| **totalDuration** | `never` | optional | [REMOVED] `StartupOrchestrationResult.totalDuration` was renamed to `totalDurationMs` 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 `totalDurationMs`; the value (milliseconds) is unchanged. | -| **allSuccessful** | `boolean` | ✅ | Whether all plugins started successfully | -| **rolledBack** | `string[]` | optional | Names of plugins that were rolled back | - -### Nested Shape: `StartupOrchestrationResult.results[number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **plugin** | `{ name: string; version?: string } & Record` | ✅ | Plugin metadata | -| **success** | `boolean` | ✅ | Whether the plugin started successfully | -| **durationMs** | `number` | ✅ | Time taken to start the plugin in milliseconds | -| **duration** | `never` | optional | [REMOVED] `PluginStartupResult.duration` was renamed to `durationMs` 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 `durationMs`; the value (milliseconds) is unchanged. | -| **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed | -| **health** | `{ healthy: boolean; checkedAt: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | - --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 3788e5d5589..594ea836943 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -260,7 +260,7 @@ directory rather than per file. | `api/` | 451 | | `identity/` | 32 | | `integration/` | 8 | -| `kernel/` | 260 | +| `kernel/` | 256 | | `marketplace/` | 29 | | `qa/` | 6 | | `shared/` | 20 | diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index ad9e188d7dd..a9650143311 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -95,7 +95,6 @@ "GenerateDraftOpts (interface)", "GenerateObjectOptions (interface)", "GrantShareInput (interface)", - "HealthStatus (type)", "HierarchyScope (type)", "HierarchyScopeContext (interface)", "HttpResponseObservation (interface)", @@ -158,7 +157,6 @@ "ISharingService (interface)", "ISmsService (interface)", "ISmsTransport (interface)", - "IStartupOrchestrator (interface)", "IStorageService (interface)", "ITeamGraphService (interface)", "ImportObjectOpts (interface)", @@ -290,8 +288,6 @@ "SharingWriteVerdict (type)", "SmsDeliveryStatus (type)", "SmsTransportSendResult (interface)", - "StartupOptions (type)", - "StartupOptionsParsed (type)", "StorageFileInfo (interface)", "StorageListOptions (interface)", "StorageListPage (interface)", diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index c583a64ed30..28c96e28e22 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -124,8 +124,6 @@ "GetPackageResponse (type)", "GetPackageResponseParsed (type)", "GetPackageResponseSchema (const)", - "HealthStatus (type)", - "HealthStatusSchema (const)", "HotReloadConfig (type)", "HotReloadConfigParsed (type)", "HotReloadConfigSchema (const)", @@ -413,11 +411,6 @@ "ServiceRegistryConfigSchema (const)", "ServiceScopeType (const)", "ServiceScopeType (type)", - "StartupOptions (type)", - "StartupOptionsParsed (type)", - "StartupOptionsSchema (const)", - "StartupOrchestrationResult (type)", - "StartupOrchestrationResultSchema (const)", "TenantRuntimeContext (type)", "TenantRuntimeContextParsed (type)", "TenantRuntimeContextSchema (const)", diff --git a/packages/spec/authorable-defaults/kernel.json b/packages/spec/authorable-defaults/kernel.json index 5517ecc6e0c..1f7d76063f4 100644 --- a/packages/spec/authorable-defaults/kernel.json +++ b/packages/spec/authorable-defaults/kernel.json @@ -138,10 +138,6 @@ "kernel/ServiceRegistryConfig:allowOverwrite = false", "kernel/ServiceRegistryConfig:enableLogging = false", "kernel/ServiceRegistryConfig:strictMode = true", - "kernel/StartupOptions:healthCheck = false", - "kernel/StartupOptions:parallel = false", - "kernel/StartupOptions:rollbackOnFailure = true", - "kernel/StartupOptions:timeoutMs = 30000", "kernel/TenantRuntimeContext:features = {}", "kernel/TenantRuntimeContext:mode = \"production\"", "kernel/UpgradePackageRequest:createSnapshot = true", diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index 476353c72ae..617c86549f4 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -205,11 +205,6 @@ "kernel/ExtensionPoint:type", "kernel/GetPackageRequest:id", "kernel/GetPackageResponse:package", - "kernel/HealthStatus:checkedAt", - "kernel/HealthStatus:details", - "kernel/HealthStatus:healthy", - "kernel/HealthStatus:message", - "kernel/HealthStatus:timestamp [RETIRED]", "kernel/HotReloadConfig:afterReload", "kernel/HotReloadConfig:beforeReload", "kernel/HotReloadConfig:debounceDelay [RETIRED]", @@ -585,9 +580,12 @@ "kernel/PluginStartupResult:duration [RETIRED]", "kernel/PluginStartupResult:durationMs", "kernel/PluginStartupResult:error", - "kernel/PluginStartupResult:health", - "kernel/PluginStartupResult:plugin", + "kernel/PluginStartupResult:health [RETIRED]", + "kernel/PluginStartupResult:plugin [RETIRED]", + "kernel/PluginStartupResult:pluginName", + "kernel/PluginStartupResult:startTime", "kernel/PluginStartupResult:success", + "kernel/PluginStartupResult:timedOut", "kernel/PluginStateSnapshot:metadata", "kernel/PluginStateSnapshot:pluginId", "kernel/PluginStateSnapshot:state", @@ -749,17 +747,6 @@ "kernel/ServiceRegistryConfig:maxServices", "kernel/ServiceRegistryConfig:scopeTypes", "kernel/ServiceRegistryConfig:strictMode", - "kernel/StartupOptions:context", - "kernel/StartupOptions:healthCheck", - "kernel/StartupOptions:parallel", - "kernel/StartupOptions:rollbackOnFailure", - "kernel/StartupOptions:timeout [RETIRED]", - "kernel/StartupOptions:timeoutMs", - "kernel/StartupOrchestrationResult:allSuccessful", - "kernel/StartupOrchestrationResult:results", - "kernel/StartupOrchestrationResult:rolledBack", - "kernel/StartupOrchestrationResult:totalDuration [RETIRED]", - "kernel/StartupOrchestrationResult:totalDurationMs", "kernel/TenantRuntimeContext:appName", "kernel/TenantRuntimeContext:cwd", "kernel/TenantRuntimeContext:features", diff --git a/packages/spec/declaration-map/kernel.json b/packages/spec/declaration-map/kernel.json index 82d5a2b3684..10ff42c6b22 100644 --- a/packages/spec/declaration-map/kernel.json +++ b/packages/spec/declaration-map/kernel.json @@ -83,8 +83,6 @@ "GetPackageRequestSchema": "kernel/GetPackageRequest", "GetPackageResponse": "kernel/GetPackageResponse", "GetPackageResponseSchema": "kernel/GetPackageResponse", - "HealthStatus": "kernel/HealthStatus", - "HealthStatusSchema": "kernel/HealthStatus", "HotReloadConfig": "kernel/HotReloadConfig", "HotReloadConfigSchema": "kernel/HotReloadConfig", "InstallPackageRequest": "kernel/InstallPackageRequest", @@ -286,10 +284,6 @@ "ServiceRegistryConfig": "kernel/ServiceRegistryConfig", "ServiceRegistryConfigSchema": "kernel/ServiceRegistryConfig", "ServiceScopeType": "kernel/ServiceScopeType", - "StartupOptions": "kernel/StartupOptions", - "StartupOptionsSchema": "kernel/StartupOptions", - "StartupOrchestrationResult": "kernel/StartupOrchestrationResult", - "StartupOrchestrationResultSchema": "kernel/StartupOrchestrationResult", "TenantRuntimeContext": "kernel/TenantRuntimeContext", "TenantRuntimeContextSchema": "kernel/TenantRuntimeContext", "UninstallPackageRequest": "kernel/UninstallPackageRequest", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 0b58bb308ae..aa45d56ee1b 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -95,7 +95,6 @@ "GenerateDraftOpts": "src/contracts/external-datasource-service.ts#GenerateDraftOpts (interface)", "GenerateObjectOptions": "src/contracts/ai-service.ts#GenerateObjectOptions (interface)", "GrantShareInput": "src/contracts/sharing-service.ts#GrantShareInput (interface)", - "HealthStatus": "src/kernel/startup-orchestrator.zod.ts#HealthStatus (type)", "HierarchyScope": "src/contracts/sharing-service.ts#HierarchyScope (type)", "HierarchyScopeContext": "src/contracts/sharing-service.ts#HierarchyScopeContext (interface)", "HttpResponseObservation": "src/contracts/http-server.ts#HttpResponseObservation (interface)", @@ -158,7 +157,6 @@ "ISharingService": "src/contracts/sharing-service.ts#ISharingService (interface)", "ISmsService": "src/contracts/sms-service.ts#ISmsService (interface)", "ISmsTransport": "src/contracts/sms-service.ts#ISmsTransport (interface)", - "IStartupOrchestrator": "src/contracts/startup-orchestrator.ts#IStartupOrchestrator (interface)", "IStorageService": "src/contracts/storage-service.ts#IStorageService (interface)", "ITeamGraphService": "src/contracts/sharing-service.ts#ITeamGraphService (interface)", "ImportObjectOpts": "src/contracts/external-datasource-service.ts#ImportObjectOpts (interface)", @@ -290,8 +288,6 @@ "SharingWriteVerdict": "src/contracts/sharing-service.ts#SharingWriteVerdict (type)", "SmsDeliveryStatus": "src/contracts/sms-service.ts#SmsDeliveryStatus (type)", "SmsTransportSendResult": "src/contracts/sms-service.ts#SmsTransportSendResult (interface)", - "StartupOptions": "src/kernel/startup-orchestrator.zod.ts#StartupOptions (type)", - "StartupOptionsParsed": "src/kernel/startup-orchestrator.zod.ts#StartupOptionsParsed (type)", "StorageFileInfo": "src/contracts/storage-service.ts#StorageFileInfo (interface)", "StorageListOptions": "src/contracts/storage-service.ts#StorageListOptions (interface)", "StorageListPage": "src/contracts/storage-service.ts#StorageListPage (interface)", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index 46f59185377..5d943613131 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -123,8 +123,6 @@ "GetPackageResponse": "src/kernel/package-registry.zod.ts#GetPackageResponse (type)", "GetPackageResponseParsed": "src/kernel/package-registry.zod.ts#GetPackageResponseParsed (type)", "GetPackageResponseSchema": "src/kernel/package-registry.zod.ts#GetPackageResponseSchema (const)", - "HealthStatus": "src/kernel/startup-orchestrator.zod.ts#HealthStatus (type)", - "HealthStatusSchema": "src/kernel/startup-orchestrator.zod.ts#HealthStatusSchema (const)", "HotReloadConfig": "src/kernel/plugin-lifecycle-advanced.zod.ts#HotReloadConfig (type)", "HotReloadConfigParsed": "src/kernel/plugin-lifecycle-advanced.zod.ts#HotReloadConfigParsed (type)", "HotReloadConfigSchema": "src/kernel/plugin-lifecycle-advanced.zod.ts#HotReloadConfigSchema (const)", @@ -410,11 +408,6 @@ "ServiceRegistryConfigParsed": "src/kernel/service-registry.zod.ts#ServiceRegistryConfigParsed (type)", "ServiceRegistryConfigSchema": "src/kernel/service-registry.zod.ts#ServiceRegistryConfigSchema (const)", "ServiceScopeType": "src/kernel/service-registry.zod.ts#ServiceScopeType (type)", - "StartupOptions": "src/kernel/startup-orchestrator.zod.ts#StartupOptions (type)", - "StartupOptionsParsed": "src/kernel/startup-orchestrator.zod.ts#StartupOptionsParsed (type)", - "StartupOptionsSchema": "src/kernel/startup-orchestrator.zod.ts#StartupOptionsSchema (const)", - "StartupOrchestrationResult": "src/kernel/startup-orchestrator.zod.ts#StartupOrchestrationResult (type)", - "StartupOrchestrationResultSchema": "src/kernel/startup-orchestrator.zod.ts#StartupOrchestrationResultSchema (const)", "TenantRuntimeContext": "src/kernel/context.zod.ts#TenantRuntimeContext (type)", "TenantRuntimeContextParsed": "src/kernel/context.zod.ts#TenantRuntimeContextParsed (type)", "TenantRuntimeContextSchema": "src/kernel/context.zod.ts#TenantRuntimeContextSchema (const)", diff --git a/packages/spec/json-schema.manifest/kernel.json b/packages/spec/json-schema.manifest/kernel.json index b5b365ad594..4fc83d18c37 100644 --- a/packages/spec/json-schema.manifest/kernel.json +++ b/packages/spec/json-schema.manifest/kernel.json @@ -44,7 +44,6 @@ "kernel/ExtensionPoint", "kernel/GetPackageRequest", "kernel/GetPackageResponse", - "kernel/HealthStatus", "kernel/HotReloadConfig", "kernel/InstallPackageRequest", "kernel/InstallPackageResponse", @@ -148,8 +147,6 @@ "kernel/ServiceMetadata", "kernel/ServiceRegistryConfig", "kernel/ServiceScopeType", - "kernel/StartupOptions", - "kernel/StartupOrchestrationResult", "kernel/TenantRuntimeContext", "kernel/UninstallPackageRequest", "kernel/UninstallPackageResponse", diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 17989df36da..346012a1637 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10611,6 +10611,74 @@ const step18: MigrationStep = { + 'of an envelope-level code; constructing an ApiError with a retired spelling ' + 'fails `StandardErrorCode`/`ApiErrorSchema` parse rather than passing silently.', }, + { + id: 'startup-orchestrator-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the startup-ORCHESTRATION surface of kernel/startup-orchestrator.zod.ts and ' + + 'contracts/startup-orchestrator.ts — 3 emitted defs and 8 exported names: ' + + 'StartupOptionsSchema / StartupOptions / StartupOptionsParsed, ' + + 'HealthStatusSchema / HealthStatus, StartupOrchestrationResultSchema / ' + + 'StartupOrchestrationResult, and the IStartupOrchestrator interface ' + + '(orchestrateStartup / rollback / checkHealth / startWithTimeout). The ' + + 'startup RESULT survives, re-declared: PluginStartupResultSchema and ' + + 'PluginStartupResult stay on both entries', + replacement: + '(removed — there is no declarative replacement, because nothing ever ' + + 'implemented the interface or parsed the schemas. Plugin startup is the ' + + 'kernel own boot loop: ObjectKernel.start() calls startPluginWithTimeout() ' + + 'per plugin, which races that plugin start() against ' + + 'PluginMetadata.startupTimeout and, when KernelConfig.rollbackOnFailure is ' + + 'set, destroys the already-started plugins and rethrows the original error ' + + 'as the new error cause. So: instead of StartupOptions.timeoutMs declare ' + + 'startupTimeout on the plugin; instead of StartupOptions.rollbackOnFailure ' + + 'set rollbackOnFailure on the kernel config; instead of ' + + 'StartupOrchestrationResult.results read the per-plugin durations through ' + + 'ObjectKernel.getPluginStartupDurations(). StartupOptions.healthCheck and ' + + 'HealthStatus have NO replacement at all — no startup probe system exists, ' + + 'and one returns only through the enforce route of ADR-0049 with a new ADR, ' + + 'the probe first and the vocabulary second. StartupOptions.parallel and ' + + 'StartupOptions.context likewise: the kernel starts plugins sequentially ' + + 'and passes its own PluginContext)', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling on #16059 (director seat, ' + + 'decision batch #60, 2026-09-06). The module declared an orchestration ' + + 'design that never landed, and the spec and the kernel had already drifted ' + + 'into disagreement about the one shape that did: PluginStartupResultSchema ' + + 'described a plugin object, a required durationMs and a health member, ' + + 'while @objectstack/core shipped pluginName, an optional durationMs and ' + + 'timedOut. The ruling keeps a startup-result contract that describes what ' + + 'the kernel actually produces, and retires the rest. Re-measured on this ' + + 'card: zero implementers and zero consumers of the four retired surfaces in ' + + 'this repository and in the pinned objectui checkout, with lit same-corpus ' + + 'controls (defineStack, ManifestSchema); every remaining reference was a ' + + 'generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus ' + + 'are the sharpest of the four: they name a per-plugin health probe the ' + + 'runtime has never had, which is the #3950 shape an AI author (ADR-0033) ' + + 'reads as proof the capability exists. With no authored document carrying ' + + 'any of the three defs there is no seam for a D2 conversion and no author to ' + + 'tombstone for: route 3, the #4834 / #11825 shape — RETIRED_DEFS_BY_MAJOR ' + + 'plus this entry ARE the declaration. The two keys of the SURVIVING result ' + + 'schema that leave (plugin, health) are tombstoned instead, and registered ' + + 'in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is ' + + 'imported by @objectstack/core.', + acceptanceCriteria: + 'No code imports any of the 8 retired names from @objectstack/spec, ' + + '@objectstack/spec/kernel or @objectstack/spec/contracts — every one is ' + + 'TS2305 after upgrade, pinned by resolved symbol identity in ' + + 'kernel/startup-orchestrator-retirement.test.ts. No metadata document needs ' + + 'editing: none of the three defs was reachable from a metadata-type ' + + 'binding, a stack collection or a manifest embed, so no authored document ' + + 'could ever carry one. PluginStartupResult SURVIVES on both entries with ' + + 'the shape the kernel ships — pluginName, success, optional durationMs, the ' + + 'deprecated startTime alias, the serializable error projection, timedOut — ' + + 'and @objectstack/core now imports that type instead of declaring a twin, ' + + 'so the drift cannot recur. Writing plugin or health on a PluginStartupResult ' + + 'is a tsc error and a parse error carrying the rename or the deletion. ' + + 'Runtime behaviour is deliberately UNCHANGED: nothing ever read the retired ' + + 'surfaces, and the kernel boot loop is untouched.', + }, { id: 'strategy-context-aggregation-method-narrowed', surface: 'StrategyContext.executeAggregate aggregations[].method ' @@ -13517,6 +13585,23 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // this rename, and the divergence between the two shapes is filed separately. // See `kernel-startup-orchestrator-durations-unit-in-key`. 'kernel/PluginStartupResult:duration', + // #16059 — `PluginStartupResult.health` held the `HealthStatus` a startup + // health check would have produced. The health-check option, the orchestrator + // interface that declared the check and the `HealthStatus` vocabulary itself + // all leave in this major (`kernel/HealthStatus` in RETIRED_DEFS_BY_MAJOR[18]), + // and nothing ever filled the key. Tombstoned rather than dropped because the + // carrying def survives — see `18.kernel__PluginStartupResult__plugin.ts` for + // why that matters here. + 'kernel/PluginStartupResult:health', + // #16059 — `PluginStartupResult.plugin` carried a nested + // `{ name, version } & Record` plugin object. The kernel has + // never built one: `ObjectKernel.startPluginWithTimeout()` has always returned + // the plugin NAME, and the re-declaration of this schema against the shipped + // shape replaces the key with `pluginName`. Tombstoned rather than dropped + // because this def keeps emitting and its type is imported by + // `@objectstack/core`, so a construction site still writing `plugin` meets the + // prescription through tsc as well as through a parse. + 'kernel/PluginStartupResult:plugin', // #15939 ruling A (per-file remediation of #14478 ruling B). This is the fifth // duration on `kernel/plugin-security-advanced.zod.ts` and the one #15678 // deliberately left alone: `resourceLimits.timeout` said "Execution timeout in @@ -15787,6 +15872,17 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // ENFORCE route of ADR-0049 through a new ADR — the implementation first. // See `18.kernel__AdvancedPluginLifecycleConfig.ts` for the family record. 'kernel/GracefulDegradation', + // #16059 — `HealthStatusSchema` (`healthy`, `checkedAt`, `details`, `message`) + // was the return vocabulary of `IStartupOrchestrator.checkHealth(plugin)` and + // the value of `PluginStartupResult.health`. Both carriers left in this same + // major, and no probe system was ever built behind either: the kernel does not + // check a plugin's health at startup, so nothing ever produced a HealthStatus. + // An exported health vocabulary with no producer reads as proof the platform + // health-checks plugins (#3950, ADR-0033). The `health` member of the surviving + // `PluginStartupResult` is tombstoned rather than dropped, because that def + // keeps emitting — see `18.kernel__PluginStartupResult__health.ts`. Route 3; + // the D3 semantic entry `startup-orchestrator-retired` carries the record. + 'kernel/HealthStatus', // #13135 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on // #12057: retirement adopted, re-scope rejected; re-charter #13135 executes // the widened surface). Part of the whole-module removal of @@ -15972,6 +16068,25 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // the production-posture hard-refusal as the first-landed half (#11846 ruling // record). 'kernel/PreviewModeConfig', + // #16059 — `StartupOptionsSchema` (`timeoutMs`, `rollbackOnFailure`, + // `healthCheck`, `parallel`, `context`) left with the `IStartupOrchestrator` + // contract it was the argument of: it was only ever the `options` parameter of + // `orchestrateStartup(plugins, options)`, and nothing in any repository + // implemented or called that method. The kernel's own boot loop reads its + // timeout from `PluginMetadata.startupTimeout` and its rollback policy from + // `KernelConfig.rollbackOnFailure`, never from this object. `healthCheck` named + // a startup probe system that does not exist. Route 3 (no authored document + // carries the def, so no tombstone and no D2 conversion): this table plus the + // D3 semantic entry `startup-orchestrator-retired` ARE the declaration. + 'kernel/StartupOptions', + // #16059 — `StartupOrchestrationResultSchema` (`results`, `totalDurationMs`, + // `allSuccessful`, `rolledBack`) was the aggregate `orchestrateStartup()` + // returned, and it left with that method: no implementation ever existed, so no + // aggregate was ever built. The kernel starts plugins one at a time and returns + // a `PluginStartupResult` per plugin; the per-plugin durations it does keep are + // reachable through `ObjectKernel.getPluginStartupDurations()`. Route 3; the D3 + // semantic entry `startup-orchestrator-retired` carries the record. + 'kernel/StartupOrchestrationResult', // #13612 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-01, director // batch C: retire; binding was weighed and not adopted). One of the six // branded identifier schemas of `shared/branded-types.zod.ts`, removed whole From 9faaefadd2a8accded4e877a7c65ca13978334d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:52:17 +0000 Subject: [PATCH 3/5] feat(spec,core)!: retire the startup orchestrator, declare the result once Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- ...6059-startup-orchestrator-shipped-shape.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .changeset/16059-startup-orchestrator-shipped-shape.md diff --git a/.changeset/16059-startup-orchestrator-shipped-shape.md b/.changeset/16059-startup-orchestrator-shipped-shape.md new file mode 100644 index 00000000000..a42ff6613ea --- /dev/null +++ b/.changeset/16059-startup-orchestrator-shipped-shape.md @@ -0,0 +1,79 @@ +--- +"@objectstack/spec": minor +"@objectstack/core": minor +--- + +feat(spec,core)!: the startup contract describes what the kernel produces — the orchestrator vocabulary is retired and `PluginStartupResult` is declared once (#16059) + + + +**BREAKING** — a published exported surface is removed, landing in the launch window as +`minor` (the lockstep convention: `major` is refused by `check-changeset-no-major`, and +breaking-ness is carried by this banner plus the ADR-0087 disposition above). + +`@objectstack/spec` declared a plugin startup ORCHESTRATOR that was never built, and its +one shape that *is* real had drifted away from the kernel that produces it. The maintainer +ruling on this card keeps a startup-result contract, and makes it describe what the kernel +actually returns. + +## What is removed + +`IStartupOrchestrator` (`orchestrateStartup` / `rollback` / `checkHealth` / +`startWithTimeout`) and the three schemas it tied together. Nothing in any repository +implemented the interface and nothing parsed the schemas; `healthCheck` and `HealthStatus` +named a per-plugin startup health probe the runtime has never had. + +| removed | from | what to write instead | +|:--|:--|:--| +| `IStartupOrchestrator` | `@objectstack/spec/contracts` | nothing — plugin startup is the kernel's own boot loop | +| `StartupOptionsSchema` / `StartupOptions` / `StartupOptionsParsed` | `@objectstack/spec/kernel`, `/contracts` | `startupTimeout` on the plugin; `rollbackOnFailure` on the kernel config | +| `StartupOptions.healthCheck` | (with the schema) | **no replacement** — no startup probe system exists | +| `HealthStatusSchema` / `HealthStatus` | `@objectstack/spec/kernel`, `/contracts` | **no replacement** — see above | +| `StartupOrchestrationResultSchema` / `StartupOrchestrationResult` | `@objectstack/spec/kernel` | `ObjectKernel.getPluginStartupDurations()` | + +`StartupOptions.parallel` and `StartupOptions.context` have no replacement either: the +kernel starts plugins sequentially and passes its own `PluginContext`. + +## What survives, re-declared + +`PluginStartupResultSchema` / `PluginStartupResult` stay on both entries, rewritten to the +shape `@objectstack/core` has always returned from `ObjectKernel.startPluginWithTimeout()`. +`@objectstack/core` now **imports** that type instead of declaring a twin, so the two +cannot drift again. + +| member | before (spec) | after (spec and core, one declaration) | +|:--|:--|:--| +| `plugin: { name, version? }` | required | **removed** — write `pluginName: string` | +| `pluginName` | absent | `string`, required | +| `success` | `boolean`, required | unchanged | +| `durationMs` | `number`, **required** | `number`, **optional** (absent when the plugin declares no `start()`) | +| `startTime` | absent | `number`, optional — deprecated alias of `durationMs`, mirrored because the kernel still populates it | +| `error` | serializable projection | unchanged (a thrown `Error` satisfies it) | +| `timedOut` | absent | `boolean`, optional — set when the failure was the timeout | +| `health: HealthStatus` | optional | **removed** — no probe ever filled it | + +**The one-line fix:** rename `plugin: { name }` to `pluginName`, and delete `health`. Both +old spellings are `retiredKey()` tombstones on the surviving schema, so each is a `tsc` +error at the construction site and a parse error carrying the prescription. + +For `@objectstack/core` consumers the members are unchanged; the one narrowing is that +`PluginStartupResult.error` is now typed as the serializable projection +(`name` / `message` / `stack?` / `code?`) rather than `Error`. The kernel still puts the +thrown instance there, so `result.error instanceof Error` still narrows — only code that +reads an `Error`-only member such as `cause` off it without that guard needs the guard. + +## The retirement kit + +Route 3 of the `spec-property-retirement` playbook: no authored document carried any of +the three defs, so there is no seam for a D2 conversion and no author to hand a tombstone +to. `RETIRED_DEFS_BY_MAJOR[18]` (`kernel/StartupOptions`, `kernel/HealthStatus`, +`kernel/StartupOrchestrationResult`) plus the D3 semantic entry +`startup-orchestrator-retired` **are** the declaration, and the three +`json-schema.manifest/kernel.json` keys plus their 16 `authorable-surface/kernel.json` +lines are deleted deliberately in this same change. The two keys of the SURVIVING result +schema (`plugin`, `health`) take the tombstone route instead, registered in +`RETIRED_KEYS_BY_MAJOR[18]`, because that def keeps emitting and its type is imported by +`@objectstack/core`. + +Runtime behaviour is deliberately unchanged: nothing ever read the retired surfaces, and +the kernel boot loop is untouched. From ac0fe6f1452725db3b9d0e2ea89d2a8076b77b6c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 15:15:33 +0000 Subject: [PATCH 4/5] fix(spec,core): tombstone the startTime alias the duration-unit rule refuses Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- ...6059-startup-orchestrator-shipped-shape.md | 18 ++++++-- .../kernel/startup-orchestrator.mdx | 9 +++- packages/core/src/kernel.test.ts | 21 ++++++---- packages/core/src/kernel.ts | 6 --- packages/core/src/plugin-loader.ts | 18 +++++--- packages/spec/authorable-surface/kernel.json | 2 +- .../startup-orchestrator-retirement.test.ts | 10 +++-- .../src/kernel/startup-orchestrator.test.ts | 27 ++++++++---- .../src/kernel/startup-orchestrator.zod.ts | 41 +++++++++++-------- ....kernel__PluginStartupResult__startTime.ts | 14 +++++++ .../18.startup-orchestrator-retired.ts | 24 +++++++---- packages/spec/src/migrations/registry.ts | 36 ++++++++++++---- 12 files changed, 157 insertions(+), 69 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__startTime.ts diff --git a/.changeset/16059-startup-orchestrator-shipped-shape.md b/.changeset/16059-startup-orchestrator-shipped-shape.md index a42ff6613ea..ddc60c360ee 100644 --- a/.changeset/16059-startup-orchestrator-shipped-shape.md +++ b/.changeset/16059-startup-orchestrator-shipped-shape.md @@ -47,14 +47,24 @@ cannot drift again. | `pluginName` | absent | `string`, required | | `success` | `boolean`, required | unchanged | | `durationMs` | `number`, **required** | `number`, **optional** (absent when the plugin declares no `start()`) | -| `startTime` | absent | `number`, optional — deprecated alias of `durationMs`, mirrored because the kernel still populates it | +| `startTime` | absent (it was core's own deprecated alias) | **removed** — read `durationMs`, which always carried the same value | | `error` | serializable projection | unchanged (a thrown `Error` satisfies it) | | `timedOut` | absent | `boolean`, optional — set when the failure was the timeout | | `health: HealthStatus` | optional | **removed** — no probe ever filled it | -**The one-line fix:** rename `plugin: { name }` to `pluginName`, and delete `health`. Both -old spellings are `retiredKey()` tombstones on the surviving schema, so each is a `tsc` -error at the construction site and a parse error carrying the prescription. +**The one-line fix:** rename `plugin: { name }` to `pluginName`, delete `health`, and read +`durationMs` wherever you read `startTime`. All three old spellings are `retiredKey()` +tombstones on the surviving schema, so each is a `tsc` error at the construction site and a +parse error carrying the prescription. + +`startTime` is the one member whose removal a reader can OBSERVE: `@objectstack/core` +populated it beside `durationMs` with the identical elapsed value, under its own ADR-0087 +L1 deprecation, and `ObjectKernel.startPluginWithTimeout()` stops setting it here. Mirroring +it on the contract was the alternative and the tree refuses it — `check:duration-unit-keys` +(ruling B on #14478) fails an elapsed number whose key name carries no unit, and neither of +that rule's two schema-declared exemptions fits: it is not an `EpochMs` instant and it +mirrors no external standard. Renaming it to `startTimeMs` would mint a spelling nothing has +ever produced, for a member already documented as slated for removal. For `@objectstack/core` consumers the members are unchanged; the one narrowing is that `PluginStartupResult.error` is now typed as the serializable projection diff --git a/content/docs/references/kernel/startup-orchestrator.mdx b/content/docs/references/kernel/startup-orchestrator.mdx index bebd30a83aa..7a75bf5ff81 100644 --- a/content/docs/references/kernel/startup-orchestrator.mdx +++ b/content/docs/references/kernel/startup-orchestrator.mdx @@ -34,7 +34,12 @@ What each member means, and when it is present: TIMEOUT rather than a throw from inside `start()`: the kernel sets it from the raced rejection's message. Absent on the success path; absent, not `false`, when a plugin's own `start()` threw. -- `startTime` — deprecated; see its own note below. + +The deprecated `startTime` alias the kernel used to set beside `durationMs` +ends here: it never held an instant, and a member whose name promises one +while carrying an elapsed duration is the confusion the duration-unit rule +exists to stop. It is a tombstone on this schema and the kernel no longer +populates it. ── [#16059] What this module used to declare, and why it no longer does ──── @@ -98,9 +103,9 @@ const result = PluginStartupResultSchema.parse(data); | **pluginName** | `string` | ✅ | Name of the plugin that was started | | **success** | `boolean` | ✅ | Whether the plugin started successfully | | **durationMs** | `number` | optional | Time taken to start the plugin in milliseconds; absent when the plugin declares no start() | -| **startTime** | `number` | optional | [DEPRECATED] The same elapsed milliseconds as durationMs — read durationMs instead | | **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed | | **timedOut** | `boolean` | optional | Whether startup failed because the startup timeout fired, rather than start() throwing | +| **startTime** | `never` | optional | [REMOVED] `PluginStartupResult.startTime` was removed in @objectstack/spec 18 (ADR-0049) — it never held an instant: the kernel filled it with the SAME elapsed milliseconds as `durationMs`, so a reader who took the name at its word and computed `Date.now() - startTime` got an age near the epoch instead of a wait. Delete the key and read `durationMs`, which has always carried the same value. | | **duration** | `never` | optional | [REMOVED] `PluginStartupResult.duration` was renamed to `durationMs` 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 `durationMs`; the value (milliseconds) is unchanged. | | **plugin** | `never` | optional | [REMOVED] `PluginStartupResult.plugin` was removed in @objectstack/spec 18 (ADR-0049) — the kernel has never put a plugin OBJECT in this result, so the nested `{ name, version }` shape described a value nothing ever built. Replace the key with `pluginName` and carry the plugin name string. | | **health** | `never` | optional | [REMOVED] `PluginStartupResult.health` was removed in @objectstack/spec 18 (ADR-0049) — it carried a `HealthStatus`, and that vocabulary is retired with the startup orchestrator that declared it: no probe system ever ran a health check at startup, so nothing ever filled the key. Delete the key; a plugin that reports health does it through a service it registers, not through this result. | diff --git a/packages/core/src/kernel.test.ts b/packages/core/src/kernel.test.ts index c0204ba88b7..047a1adf361 100644 --- a/packages/core/src/kernel.test.ts +++ b/packages/core/src/kernel.test.ts @@ -585,10 +585,12 @@ describe('ObjectKernel', () => { }); // These two pin the MEANING of the number, not merely that one is - // present. The result member carrying it was spelled `startTime` while - // holding `Date.now() - start`, so a reader who correctly took it for an - // instant and wrote `Date.now() - result.startTime` got an age near the - // epoch. `toBeGreaterThan(0)` cannot tell the two readings apart -- an + // present. The result member carrying it was once ALSO spelled + // `startTime` while holding `Date.now() - start`, so a reader who + // correctly took that name for an instant and wrote + // `Date.now() - result.startTime` got an age near the epoch. #16059 + // removed that alias; `durationMs` is the only spelling left, and + // `toBeGreaterThan(0)` cannot tell the two readings apart -- an // epoch-millisecond instant passes it too. A ceiling can: any instant // today is ~1.7e12, orders of magnitude above any plugin's start(). const INSTANT_FLOOR_MS = 1_000_000_000; // ~11.5 days as a duration; well below any real epoch-ms instant @@ -636,8 +638,13 @@ describe('ObjectKernel', () => { expect(ok.success).toBe(true); expect(ok.durationMs).toBeGreaterThan(0); expect(ok.durationMs).toBeLessThan(INSTANT_FLOOR_MS); - // The deprecated alias carries the same elapsed value, not an instant. - expect(ok.startTime).toBe(ok.durationMs); + // [#16059] The `startTime` alias that used to carry the same value is + // GONE, not merely deprecated: a member whose name promises an + // instant while holding an elapsed duration is the confusion this + // case exists to pin, and the spec now tombstones it. Asserting its + // absence here is what keeps a later "restore the alias" from + // passing quietly. + expect(ok).not.toHaveProperty('startTime'); const failingMeta: PluginMetadata = { name: 'failing-plugin', @@ -652,7 +659,7 @@ describe('ObjectKernel', () => { expect(failed.success).toBe(false); expect(failed.durationMs).toBeGreaterThanOrEqual(0); expect(failed.durationMs).toBeLessThan(INSTANT_FLOOR_MS); - expect(failed.startTime).toBe(failed.durationMs); + expect(failed).not.toHaveProperty('startTime'); }); }); diff --git a/packages/core/src/kernel.ts b/packages/core/src/kernel.ts index 55b34588317..57d2340cae4 100644 --- a/packages/core/src/kernel.ts +++ b/packages/core/src/kernel.ts @@ -792,9 +792,6 @@ export class ObjectKernel { success: true, pluginName: plugin.name, durationMs: duration, - // Deprecated alias carrying the same elapsed value; see - // PluginStartupResult.startTime. - startTime: duration, }; } catch (error) { const duration = Date.now() - startTime; @@ -805,9 +802,6 @@ export class ObjectKernel { pluginName: plugin.name, error: error as Error, durationMs: duration, - // Deprecated alias carrying the same elapsed value; see - // PluginStartupResult.startTime. - startTime: duration, timedOut: isTimeout, }; } diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 4f232f01c81..4240886dbc8 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -99,12 +99,18 @@ export interface PluginLoadResult { * SCHEMA against what this package actually ships and made the type flow one * way — spec to core — so the two cannot drift apart again. * - * The shape is unchanged for every producer and reader here: `pluginName`, - * `success`, optional `durationMs`, the deprecated `startTime` alias, - * `timedOut`, and `error` — declared by the spec as the serializable projection - * (`name` / `message` / `stack` / `code`) that a thrown `Error` satisfies - * structurally, so `ObjectKernel` keeps handing the live instance through and - * `instanceof Error` keeps narrowing at the read site. + * The members carry over unchanged — `pluginName`, `success`, optional + * `durationMs`, `timedOut`, and `error`, which the spec declares as the + * serializable projection (`name` / `message` / `stack` / `code`) that a thrown + * `Error` satisfies structurally, so `ObjectKernel` keeps handing the live + * instance through and `instanceof Error` keeps narrowing at the read site. + * + * One member does NOT carry over: the deprecated `startTime` alias, which held + * the same elapsed milliseconds as `durationMs` under a name that promises an + * instant. The spec tombstones it rather than mirroring it — + * `check:duration-unit-keys` refuses an elapsed number whose key name carries + * no unit, and neither of that rule's schema-declared exemptions fits — so the + * L1 window closes here and `ObjectKernel` no longer populates it. */ export type { PluginStartupResult } from '@objectstack/spec/kernel'; diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index 617c86549f4..096518f82a6 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -583,7 +583,7 @@ "kernel/PluginStartupResult:health [RETIRED]", "kernel/PluginStartupResult:plugin [RETIRED]", "kernel/PluginStartupResult:pluginName", - "kernel/PluginStartupResult:startTime", + "kernel/PluginStartupResult:startTime [RETIRED]", "kernel/PluginStartupResult:success", "kernel/PluginStartupResult:timedOut", "kernel/PluginStateSnapshot:metadata", diff --git a/packages/spec/src/kernel/startup-orchestrator-retirement.test.ts b/packages/spec/src/kernel/startup-orchestrator-retirement.test.ts index 2a07be2dbb2..06c501b9d95 100644 --- a/packages/spec/src/kernel/startup-orchestrator-retirement.test.ts +++ b/packages/spec/src/kernel/startup-orchestrator-retirement.test.ts @@ -117,12 +117,14 @@ describe('[#16059] startup orchestrator retirement', () => { // asserts the shape here too, so the reason is named at the spec end. const { PluginStartupResultSchema } = await import('./startup-orchestrator.zod'); const shape = (PluginStartupResultSchema as unknown as { shape: Record }).shape; - for (const member of ['pluginName', 'success', 'durationMs', 'startTime', 'error', 'timedOut']) { + for (const member of ['pluginName', 'success', 'durationMs', 'error', 'timedOut']) { expect(Object.keys(shape), `${member} must be declared`).toContain(member); } - // The two members that left the surviving def are tombstones, not - // deletions — present in the shape, refusing with a prescription. - for (const member of ['plugin', 'health', 'duration']) { + // The members that left the surviving def are tombstones, not deletions — + // present in the shape, refusing with a prescription. `startTime` is here + // rather than above on purpose: mirroring the deprecated alias is what + // `check:duration-unit-keys` refuses, so the contract ends it instead. + for (const member of ['plugin', 'health', 'startTime', 'duration']) { expect(Object.keys(shape), `${member} must remain as a tombstone`).toContain(member); } }); diff --git a/packages/spec/src/kernel/startup-orchestrator.test.ts b/packages/spec/src/kernel/startup-orchestrator.test.ts index e56df89abfe..597ad3cbf84 100644 --- a/packages/spec/src/kernel/startup-orchestrator.test.ts +++ b/packages/spec/src/kernel/startup-orchestrator.test.ts @@ -17,21 +17,14 @@ describe('PluginStartupResultSchema — the shape the kernel produces', () => { expect(result.success).toBe(true); }); - it('accepts the success path: durationMs plus the deprecated startTime alias', () => { + it('accepts the success path: durationMs alone', () => { const result = PluginStartupResultSchema.safeParse({ pluginName: 'crm-plugin', success: true, durationMs: 1250, - startTime: 1250, }); expect(result.success).toBe(true); - if (result.success) { - expect(result.data.durationMs).toBe(1250); - // The kernel populates both with the SAME elapsed value under its - // ADR-0087 L1 disposition; the contract mirrors that rather than - // declaring `never` for a member the kernel emits. - expect(result.data.startTime).toBe(result.data.durationMs); - } + if (result.success) expect(result.data.durationMs).toBe(1250); }); it('accepts the failure path: the serializable error projection', () => { @@ -112,6 +105,22 @@ describe('[#16059] the re-declared result refuses the members it dropped', () => expect(issue!.message).toContain('Replace the key with `pluginName`'); }); + it('REFUSES the deprecated `startTime` alias and prescribes `durationMs`', () => { + const result = PluginStartupResultSchema.safeParse({ + pluginName: 'crm-plugin', + success: true, + durationMs: 1250, + startTime: 1250, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'startTime'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + // The prescription is the payload: the name promised an instant and the + // value was always an elapsed duration. + expect(issue!.message).toMatch(/startTime.*removed.*read `durationMs`/s); + }); + it('REFUSES `health` and says no startup probe system exists', () => { const result = PluginStartupResultSchema.safeParse({ pluginName: 'crm-plugin', diff --git a/packages/spec/src/kernel/startup-orchestrator.zod.ts b/packages/spec/src/kernel/startup-orchestrator.zod.ts index 2d2ae18d9a7..1fa6518cca7 100644 --- a/packages/spec/src/kernel/startup-orchestrator.zod.ts +++ b/packages/spec/src/kernel/startup-orchestrator.zod.ts @@ -35,7 +35,12 @@ import { retiredKey } from '../shared/retired-key'; * TIMEOUT rather than a throw from inside `start()`: the kernel sets it from * the raced rejection's message. Absent on the success path; absent, not * `false`, when a plugin's own `start()` threw. - * - `startTime` — deprecated; see its own note below. + * + * The deprecated `startTime` alias the kernel used to set beside `durationMs` + * ends here: it never held an instant, and a member whose name promises one + * while carrying an elapsed duration is the confusion the duration-unit rule + * exists to stop. It is a tombstone on this schema and the kernel no longer + * populates it. * * ── [#16059] What this module used to declare, and why it no longer does ──── * @@ -121,21 +126,6 @@ export const PluginStartupResultSchema = lazySchema(() => z.object({ durationMs: z.number().min(0).optional() .describe('Time taken to start the plugin in milliseconds; absent when the plugin declares no start()'), - /** - * The same elapsed milliseconds as `durationMs`. - * - * @deprecated Misnamed: it has never held an instant, so a reader who - * correctly takes `startTime` for one and computes `Date.now() - startTime` - * gets an age near the epoch instead of a wait. Read `durationMs` instead. - * Mirrored here because `@objectstack/core` still POPULATES it under its - * ADR-0087 L1 disposition — the old shape keeps working while the fleet - * moves — and a contract that declared `never` for a member the kernel emits - * would be the very drift #16059 closed. It leaves this schema in the same - * change that stops the kernel emitting it. - */ - startTime: z.number().min(0).optional() - .describe('[DEPRECATED] The same elapsed milliseconds as durationMs — read durationMs instead'), - /** * Error if startup failed */ @@ -153,6 +143,25 @@ export const PluginStartupResultSchema = lazySchema(() => z.object({ timedOut: z.boolean().optional() .describe('Whether startup failed because the startup timeout fired, rather than start() throwing'), + /** + * Tombstone for the deprecated `startTime` alias core carried (#16059). + * + * Mirroring it was the other candidate and the tree refuses it: the member + * holds elapsed milliseconds under a name that carries no unit, which is + * exactly what `check:duration-unit-keys` (ruling B on #14478) fails, and + * neither of that rule's two schema-declared exemptions applies — it is not + * an `EpochMs` instant and it mirrors no external standard. Renaming it to + * `startTimeMs` would mint a spelling nothing has ever produced, for a member + * already slated for removal. So the L1 alias ends here, audibly. + */ + startTime: retiredKey( + '`PluginStartupResult.startTime` was removed in @objectstack/spec 18 (ADR-0049) — ' + + 'it never held an instant: the kernel filled it with the SAME elapsed milliseconds as ' + + '`durationMs`, so a reader who took the name at its word and computed ' + + '`Date.now() - startTime` got an age near the epoch instead of a wait. Delete the key ' + + 'and read `durationMs`, which has always carried the same value.', + ), + /** Tombstone for the `duration` → `durationMs` rename (#15678, ruling B on #14478). */ duration: retiredKey( '`PluginStartupResult.duration` was renamed to `durationMs` in @objectstack/spec 17 — ' diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__startTime.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__startTime.ts new file mode 100644 index 00000000000..aaf7adc65db --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__startTime.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16059 — `PluginStartupResult.startTime` was `@objectstack/core`'s own +// ADR-0087 L1 alias: the kernel set it to the SAME elapsed milliseconds as +// `durationMs`, under a name that promises an instant. It arrives on the spec's +// surface only to leave it, because the re-declaration of this schema against +// the shipped shape had to choose between mirroring the member and tombstoning +// it, and mirroring is refused by `check:duration-unit-keys` (ruling B on +// #14478): an elapsed number whose key name carries no unit, matching neither +// of that rule's two schema-declared exemptions — not an `EpochMs` instant, not +// an external-standard mirror. Renaming it to `startTimeMs` would mint a +// spelling nothing ever produced for a member already slated for removal, so +// the alias ends here and the kernel stops populating it in the same change. +export const entry = 'kernel/PluginStartupResult:startTime'; diff --git a/packages/spec/src/migrations/entries/semantic/18.startup-orchestrator-retired.ts b/packages/spec/src/migrations/entries/semantic/18.startup-orchestrator-retired.ts index b7b9d02a6af..114661e4c56 100644 --- a/packages/spec/src/migrations/entries/semantic/18.startup-orchestrator-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.startup-orchestrator-retired.ts @@ -53,7 +53,14 @@ export const entry: SemanticMigration = { + 'plus this entry ARE the declaration. The two keys of the SURVIVING result ' + 'schema that leave (plugin, health) are tombstoned instead, and registered ' + 'in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is ' - + 'imported by @objectstack/core.', + + 'imported by @objectstack/core. A third key arrives on the spec surface only ' + + 'to leave it: core deprecated startTime alias, which held the same elapsed ' + + 'milliseconds as durationMs under a name that promises an instant. The ' + + 're-declaration had to either mirror it or tombstone it, and mirroring is ' + + 'refused by check:duration-unit-keys (ruling B on #14478) since it is an ' + + 'elapsed number whose key name carries no unit and matches neither of that ' + + 'rule two schema-declared exemptions. So the L1 window closes here and the ' + + 'kernel stops populating it in the same change.', acceptanceCriteria: 'No code imports any of the 8 retired names from @objectstack/spec, ' + '@objectstack/spec/kernel or @objectstack/spec/contracts — every one is ' @@ -63,10 +70,13 @@ export const entry: SemanticMigration = { + 'binding, a stack collection or a manifest embed, so no authored document ' + 'could ever carry one. PluginStartupResult SURVIVES on both entries with ' + 'the shape the kernel ships — pluginName, success, optional durationMs, the ' - + 'deprecated startTime alias, the serializable error projection, timedOut — ' - + 'and @objectstack/core now imports that type instead of declaring a twin, ' - + 'so the drift cannot recur. Writing plugin or health on a PluginStartupResult ' - + 'is a tsc error and a parse error carrying the rename or the deletion. ' - + 'Runtime behaviour is deliberately UNCHANGED: nothing ever read the retired ' - + 'surfaces, and the kernel boot loop is untouched.', + + 'serializable error projection, timedOut — and @objectstack/core now imports ' + + 'that type instead of declaring a twin, so the drift cannot recur. Writing ' + + 'plugin, health or startTime on a PluginStartupResult is a tsc error and a ' + + 'parse error carrying the rename or the deletion; a reader of the removed ' + + 'startTime alias reads durationMs, which has always carried the same value. ' + + 'Runtime behaviour is unchanged except for that one alias: nothing ever read ' + + 'the retired ORCHESTRATION surfaces, the kernel boot loop is untouched, and ' + + 'the only observable difference is that a startup result no longer carries ' + + 'startTime beside durationMs.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 346012a1637..97a4a1d823a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10662,7 +10662,14 @@ const step18: MigrationStep = { + 'plus this entry ARE the declaration. The two keys of the SURVIVING result ' + 'schema that leave (plugin, health) are tombstoned instead, and registered ' + 'in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is ' - + 'imported by @objectstack/core.', + + 'imported by @objectstack/core. A third key arrives on the spec surface only ' + + 'to leave it: core deprecated startTime alias, which held the same elapsed ' + + 'milliseconds as durationMs under a name that promises an instant. The ' + + 're-declaration had to either mirror it or tombstone it, and mirroring is ' + + 'refused by check:duration-unit-keys (ruling B on #14478) since it is an ' + + 'elapsed number whose key name carries no unit and matches neither of that ' + + 'rule two schema-declared exemptions. So the L1 window closes here and the ' + + 'kernel stops populating it in the same change.', acceptanceCriteria: 'No code imports any of the 8 retired names from @objectstack/spec, ' + '@objectstack/spec/kernel or @objectstack/spec/contracts — every one is ' @@ -10672,12 +10679,15 @@ const step18: MigrationStep = { + 'binding, a stack collection or a manifest embed, so no authored document ' + 'could ever carry one. PluginStartupResult SURVIVES on both entries with ' + 'the shape the kernel ships — pluginName, success, optional durationMs, the ' - + 'deprecated startTime alias, the serializable error projection, timedOut — ' - + 'and @objectstack/core now imports that type instead of declaring a twin, ' - + 'so the drift cannot recur. Writing plugin or health on a PluginStartupResult ' - + 'is a tsc error and a parse error carrying the rename or the deletion. ' - + 'Runtime behaviour is deliberately UNCHANGED: nothing ever read the retired ' - + 'surfaces, and the kernel boot loop is untouched.', + + 'serializable error projection, timedOut — and @objectstack/core now imports ' + + 'that type instead of declaring a twin, so the drift cannot recur. Writing ' + + 'plugin, health or startTime on a PluginStartupResult is a tsc error and a ' + + 'parse error carrying the rename or the deletion; a reader of the removed ' + + 'startTime alias reads durationMs, which has always carried the same value. ' + + 'Runtime behaviour is unchanged except for that one alias: nothing ever read ' + + 'the retired ORCHESTRATION surfaces, the kernel boot loop is untouched, and ' + + 'the only observable difference is that a startup result no longer carries ' + + 'startTime beside durationMs.', }, { id: 'strategy-context-aggregation-method-narrowed', @@ -13602,6 +13612,18 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `@objectstack/core`, so a construction site still writing `plugin` meets the // prescription through tsc as well as through a parse. 'kernel/PluginStartupResult:plugin', + // #16059 — `PluginStartupResult.startTime` was `@objectstack/core`'s own + // ADR-0087 L1 alias: the kernel set it to the SAME elapsed milliseconds as + // `durationMs`, under a name that promises an instant. It arrives on the spec's + // surface only to leave it, because the re-declaration of this schema against + // the shipped shape had to choose between mirroring the member and tombstoning + // it, and mirroring is refused by `check:duration-unit-keys` (ruling B on + // #14478): an elapsed number whose key name carries no unit, matching neither + // of that rule's two schema-declared exemptions — not an `EpochMs` instant, not + // an external-standard mirror. Renaming it to `startTimeMs` would mint a + // spelling nothing ever produced for a member already slated for removal, so + // the alias ends here and the kernel stops populating it in the same change. + 'kernel/PluginStartupResult:startTime', // #15939 ruling A (per-file remediation of #14478 ruling B). This is the fifth // duration on `kernel/plugin-security-advanced.zod.ts` and the one #15678 // deliberately left alone: `resourceLimits.timeout` said "Execution timeout in From e345ffa9e179ac123cc983a68714df45db430641 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 15:24:44 +0000 Subject: [PATCH 5/5] docs: the quick-reference row names the surviving startup result Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- content/docs/getting-started/quick-reference.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 3aad59cbff4..ef079cef641 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -80,7 +80,7 @@ Plugin architecture, manifests, and kernel runtime. | **[Plugin Validator](/docs/references/kernel/plugin-validator)** | `plugin-validator.zod.ts` | ValidationResult, PluginMetadata | Plugin validation | | **[Plugin Versioning](/docs/references/kernel/plugin-versioning)** | `plugin-versioning.zod.ts` | PluginCompatibilityMatrix, DeprecationNotice | Version compatibility | | **[Service Registry](/docs/references/kernel/service-registry)** | `service-registry.zod.ts` | ServiceRegistryConfig, ServiceMetadata | Service discovery | -| **[Startup Orchestrator](/docs/references/kernel/startup-orchestrator)** | `startup-orchestrator.zod.ts` | StartupOptions, StartupOrchestrationResult | System startup | +| **[Startup Orchestrator](/docs/references/kernel/startup-orchestrator)** | `startup-orchestrator.zod.ts` | PluginStartupResult | The per-plugin result the kernel returns for every plugin it starts | | **[Events](/docs/kernel/events)** ↗ | `events.zod.ts` | Event, EventBusConfig | System event bus — the hand-written guide, outside `references/kernel/` (which splits the same surface across six `events-*` pages) | | **[Metadata Loader](/docs/references/kernel/metadata-loader)** | `metadata-loader.zod.ts` | MetadataLoaderContract | Metadata loading | | **[Package Registry](/docs/references/kernel/package-registry)** | `package-registry.zod.ts` | InstalledPackage, InstallPackageRequest | Package resolution |