From 9fd8fdae70fc8101dbcf90961780b49d12af56c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 18:10:12 +0000 Subject: [PATCH 1/2] fix(spec): retire the never-built typed-event system; registry now lists the events that actually fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lifecycle-event surface promised a typed-event system that was never built, in three layers (#4212 leftovers, ADR-0049 enforce-or-remove): - kernel/plugin-lifecycle-events.zod.ts: 346 lines of payload schemas + a 21-name event enum — zero consumers for every export, and the enum both listed 17 names nothing fires and missed 10 events that DO fire. Deleted, with its test and barrel line; the 13 schemas leave json-schema.manifest.json and authorable-surface.json in the same PR (sanctioned deliberate-removal path; the keys were never authorable — they described runtime event payloads no config author can write, so the silent-strip class the ratchet guards against is vacuous here). - contracts/plugin-lifecycle-events.ts: IPluginLifecycleEvents kept the same 17 dead names alongside 5 real ones, plus an ITypedEventEmitter nothing implements. Rewritten as the registry of the 14 events with a real emitter — kernel:{ready,bootstrapped,listening,shutdown}, app:seeded, metadata:reloaded (metadata now optional, matching the documented contract), external.schema.drift, ai:routes (out-of-repo emitter, in-repo listener), auth:configure, and the {service}:ready convention family (mcp, automation, analytics, external-datasource, datasource-admin) — each with its payload as observed at the fire site. - Enforcement so the registry cannot rot again: LifecycleEventName now types IPluginContext.hook/trigger in @objectstack/core as LifecycleEventName | (string & {}) — known names autocomplete, custom cross-plugin names stay legal — and a pinning test asserts two-way equality between the interface keys and the fire-site inventory. Docs follow the code: the generated reference page for the deleted zod file is gone (nav + index links updated via gen:docs), PROTOCOL_MAP row repointed at the contracts registry, skill reference index regenerated. Verified: spec 7142 tests green (275 files), core 437 green, spec tsc --noEmit clean, and the full generated-artifact gate family in sync (docs 251, skill refs/docs/examples, api-surface, authorable-surface, spec-changes, upgrade-guide, exported-any). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh --- .../docs/getting-started/quick-reference.mdx | 1 - content/docs/references/index.mdx | 1 - content/docs/references/kernel/index.mdx | 1 - content/docs/references/kernel/meta.json | 1 - .../kernel/plugin-lifecycle-events.mdx | 220 ----------- packages/core/src/types.ts | 18 +- packages/spec/PROTOCOL_MAP.md | 2 +- packages/spec/api-surface.json | 25 +- packages/spec/authorable-surface.json | 33 -- packages/spec/json-schema.manifest.json | 13 - .../spec/scripts/build-skill-references.ts | 4 +- .../contracts/plugin-lifecycle-events.test.ts | 217 +++++------ .../src/contracts/plugin-lifecycle-events.ts | 288 +++++++-------- packages/spec/src/kernel/index.ts | 1 - .../kernel/plugin-lifecycle-events.test.ts | 227 ------------ .../src/kernel/plugin-lifecycle-events.zod.ts | 346 ------------------ .../objectstack-platform/references/_index.md | 1 - 17 files changed, 235 insertions(+), 1164 deletions(-) delete mode 100644 content/docs/references/kernel/plugin-lifecycle-events.mdx delete mode 100644 packages/spec/src/kernel/plugin-lifecycle-events.test.ts delete mode 100644 packages/spec/src/kernel/plugin-lifecycle-events.zod.ts diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index ec957745f4..6600eb94a7 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -64,7 +64,6 @@ Plugin architecture, manifests, and kernel runtime. | **[Context](/docs/references/kernel/context)** | `context.zod.ts` | KernelContext | Runtime execution context | | **[Plugin](/docs/references/kernel/plugin)** | `plugin.zod.ts` | Plugin, PluginLifecycle | Plugin system interface | | **[Plugin Capability](/docs/references/kernel/plugin-capability)** | `plugin-capability.zod.ts` | PluginCapability | Plugin capability declarations | -| **[Plugin Lifecycle](/docs/references/kernel/plugin-lifecycle-events)** | `plugin-lifecycle-events.zod.ts` | PluginEventBase, EventPhase | Plugin lifecycle events | | **[Plugin Lifecycle Advanced](/docs/references/kernel/plugin-lifecycle-advanced)** | `plugin-lifecycle-advanced.zod.ts` | AdvancedPluginLifecycleConfig, PluginHealthCheck | Advanced lifecycle hooks | | **[Plugin Loading](/docs/references/kernel/plugin-loading)** | `plugin-loading.zod.ts` | PluginLoadingConfig | Plugin loading and init | | **[Plugin Security](/docs/references/kernel/plugin-security-advanced)** | `plugin-security-advanced.zod.ts` | KernelSecurityPolicy, PluginPermission | Plugin sandboxing | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 52e1172934..07c912df63 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -115,7 +115,6 @@ Defines the plugin architecture and kernel runtime. | `context.zod.ts` | `KernelContextSchema` | Kernel execution context with user, org, tenant info | | `plugin.zod.ts` | `PluginSchema` | Plugin lifecycle and interface definitions | | `plugin-capability.zod.ts` | `PluginCapabilitySchema` | Plugin capability declarations | -| `plugin-lifecycle-events.zod.ts` | `PluginLifecycleEventsSchema` | Plugin lifecycle event definitions | | `plugin-lifecycle-advanced.zod.ts` | `PluginLifecycleAdvancedSchema` | Advanced lifecycle hooks | | `plugin-loading.zod.ts` | `PluginLoadingSchema` | Plugin loading and initialization | | `plugin-security-advanced.zod.ts` | `PluginSecurityAdvancedSchema` | Plugin security and sandboxing | diff --git a/content/docs/references/kernel/index.mdx b/content/docs/references/kernel/index.mdx index a969832035..c4d3f3c9a0 100644 --- a/content/docs/references/kernel/index.mdx +++ b/content/docs/references/kernel/index.mdx @@ -22,7 +22,6 @@ This section contains all protocol schemas for the kernel layer of ObjectStack. - diff --git a/content/docs/references/kernel/meta.json b/content/docs/references/kernel/meta.json index 0fd274d631..a44766a69c 100644 --- a/content/docs/references/kernel/meta.json +++ b/content/docs/references/kernel/meta.json @@ -4,7 +4,6 @@ "---Plugin Lifecycle---", "plugin", "plugin-lifecycle-advanced", - "plugin-lifecycle-events", "plugin-loading", "plugin-registry", "plugin-runtime", diff --git a/content/docs/references/kernel/plugin-lifecycle-events.mdx b/content/docs/references/kernel/plugin-lifecycle-events.mdx deleted file mode 100644 index 4b9f47fd05..0000000000 --- a/content/docs/references/kernel/plugin-lifecycle-events.mdx +++ /dev/null @@ -1,220 +0,0 @@ ---- -title: Plugin Lifecycle Events -description: Plugin Lifecycle Events protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -Plugin Lifecycle Events Protocol - -Zod schemas for plugin lifecycle event data structures. - -These schemas align with the IPluginLifecycleEvents contract interface. - -Following ObjectStack "Zod First" principle - all data structures - -must have Zod schemas for runtime validation and JSON Schema generation. - - -**Source:** `packages/spec/src/kernel/plugin-lifecycle-events.zod.ts` - - -## TypeScript Usage - -```typescript -import { EventPhase, HookRegisteredEvent, HookTriggeredEvent, KernelEventBase, KernelReadyEvent, KernelShutdownEvent, PluginErrorEvent, PluginEventBase, PluginLifecycleEventType, PluginLifecyclePhaseEvent, PluginRegisteredEvent, ServiceRegisteredEvent, ServiceUnregisteredEvent } from '@objectstack/spec/kernel'; -import type { EventPhase, HookRegisteredEvent, HookTriggeredEvent, KernelEventBase, KernelReadyEvent, KernelShutdownEvent, PluginErrorEvent, PluginEventBase, PluginLifecycleEventType, PluginLifecyclePhaseEvent, PluginRegisteredEvent, ServiceRegisteredEvent, ServiceUnregisteredEvent } from '@objectstack/spec/kernel'; - -// Validate data -const result = EventPhase.parse(data); -``` - ---- - -## EventPhase - -Plugin lifecycle phase - -### Allowed Values - -* `init` -* `start` -* `destroy` - - ---- - -## HookRegisteredEvent - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **hookName** | `string` | ✅ | Name of the hook | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds | -| **handlerCount** | `integer` | ✅ | Number of handlers registered for this hook | - - ---- - -## HookTriggeredEvent - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **hookName** | `string` | ✅ | Name of the hook | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds | -| **args** | `any[]` | ✅ | Arguments passed to the hook handlers | -| **handlerCount** | `integer` | optional | Number of handlers that will handle this event | - - ---- - -## KernelEventBase - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds | - - ---- - -## KernelReadyEvent - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds | -| **duration** | `number` | optional | Total initialization duration in milliseconds | -| **pluginCount** | `integer` | optional | Number of plugins initialized | - - ---- - -## KernelShutdownEvent - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds | -| **reason** | `string` | optional | Reason for kernel shutdown | - - ---- - -## PluginErrorEvent - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **pluginName** | `string` | ✅ | Name of the plugin | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds when event occurred | -| **error** | `{ name: string; message: string; stack?: string; code?: string }` | ✅ | Serializable error representation | -| **phase** | `Enum<'init' \| 'start' \| 'destroy'>` | ✅ | Lifecycle phase where error occurred | -| **errorMessage** | `string` | optional | Error message | -| **errorStack** | `string` | optional | Error stack trace | - - ---- - -## PluginEventBase - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **pluginName** | `string` | ✅ | Name of the plugin | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds when event occurred | - - ---- - -## PluginLifecycleEventType - -Plugin lifecycle event type - -### Allowed Values - -* `kernel:ready` -* `kernel:bootstrapped` -* `kernel:listening` -* `kernel:shutdown` -* `kernel:before-init` -* `kernel:after-init` -* `plugin:registered` -* `plugin:before-init` -* `plugin:init` -* `plugin:after-init` -* `plugin:before-start` -* `plugin:started` -* `plugin:after-start` -* `plugin:before-destroy` -* `plugin:destroyed` -* `plugin:after-destroy` -* `plugin:error` -* `service:registered` -* `service:unregistered` -* `hook:registered` -* `hook:triggered` - - ---- - -## PluginLifecyclePhaseEvent - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **pluginName** | `string` | ✅ | Name of the plugin | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds when event occurred | -| **duration** | `number` | optional | Duration of the lifecycle phase in milliseconds | -| **phase** | `Enum<'init' \| 'start' \| 'destroy'>` | optional | Lifecycle phase | - - ---- - -## PluginRegisteredEvent - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **pluginName** | `string` | ✅ | Name of the plugin | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds when event occurred | -| **version** | `string` | optional | Plugin version | - - ---- - -## ServiceRegisteredEvent - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **serviceName** | `string` | ✅ | Name of the registered service | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds | -| **serviceType** | `string` | optional | Type or interface name of the service | - - ---- - -## ServiceUnregisteredEvent - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **serviceName** | `string` | ✅ | Name of the unregistered service | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds | - - ---- - diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 902f913874..68238876fd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectKernel } from './kernel.js'; -import type { Logger } from '@objectstack/spec/contracts'; +import type { Logger, LifecycleEventName } from '@objectstack/spec/contracts'; /** * PluginContext - Runtime context available to plugins @@ -58,18 +58,26 @@ export interface PluginContext { getServices(): Map; /** - * Register a hook handler + * Register a hook handler. + * + * Known lifecycle-bus names (see `IPluginLifecycleEvents` in + * `@objectstack/spec`) autocomplete; the bus stays open to custom + * cross-plugin event names, so any string remains valid. + * * @param name - Hook name (e.g., 'kernel:ready', 'data:beforeInsert') * @param handler - Hook handler function */ - hook(name: string, handler: (...args: any[]) => void | Promise): void; + hook( + name: LifecycleEventName | (string & {}), + handler: (...args: any[]) => void | Promise, + ): void; /** * Trigger a hook - * @param name - Hook name + * @param name - Hook name (known lifecycle names autocomplete; custom names stay legal) * @param args - Arguments to pass to hook handlers */ - trigger(name: string, ...args: any[]): Promise; + trigger(name: LifecycleEventName | (string & {}), ...args: any[]): Promise; /** * Logger instance diff --git a/packages/spec/PROTOCOL_MAP.md b/packages/spec/PROTOCOL_MAP.md index 94001cd5c1..6ebe74467c 100644 --- a/packages/spec/PROTOCOL_MAP.md +++ b/packages/spec/PROTOCOL_MAP.md @@ -202,7 +202,7 @@ This document serves as the **Grand Map** of the ObjectStack specification. It l | [`plugin-validator.zod.ts`](src/kernel/plugin-validator.zod.ts) | | **Validation**. Integrity checks for plugins. | | [`plugin-structure.zod.ts`](src/kernel/plugin-structure.zod.ts) | | **Structure**. Zod rules for folder layout and file naming. | | [`plugin-capability.zod.ts`](src/kernel/plugin-capability.zod.ts) | | **Capabilities**. What a plugin can do. | -| [`plugin-lifecycle-events.zod.ts`](src/kernel/plugin-lifecycle-events.zod.ts) | | **Lifecycle Events**. Hooks for plugin state changes. | +| [`plugin-lifecycle-events.ts`](src/contracts/plugin-lifecycle-events.ts) | | **Lifecycle Events**. Registry of every kernel-bus event that actually fires, with payload tuples; feeds `hook`/`trigger` autocomplete. | | [`plugin-lifecycle-advanced.zod.ts`](src/kernel/plugin-lifecycle-advanced.zod.ts) | | **Advanced Lifecycle**. Health monitoring, hot reload state management, graceful degradation, and update strategies. | | [`plugin-security-advanced.zod.ts`](src/kernel/plugin-security-advanced.zod.ts) | | **Advanced Security**. Permission system, sandbox configuration (V8/WASM/container/process), security scanning, and trust levels. | | [`startup-orchestrator.zod.ts`](src/kernel/startup-orchestrator.zod.ts) | | **Startup**. Boot sequence orchestration. | diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 3c011d5e16..78fd26cf57 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1473,8 +1473,6 @@ "EventMetadataSchema (const)", "EventPersistence (type)", "EventPersistenceSchema (const)", - "EventPhase (type)", - "EventPhaseSchema (const)", "EventPriority (type)", "EventQueueConfig (type)", "EventQueueConfigSchema (const)", @@ -1506,10 +1504,6 @@ "GracefulDegradationSchema (const)", "HealthStatus (type)", "HealthStatusSchema (const)", - "HookRegisteredEvent (type)", - "HookRegisteredEventSchema (const)", - "HookTriggeredEvent (type)", - "HookTriggeredEventSchema (const)", "HotReloadConfig (type)", "HotReloadConfigSchema (const)", "InstallPackageRequest (type)", @@ -1520,17 +1514,12 @@ "InstalledPackageSchema (const)", "KernelContext (type)", "KernelContextSchema (const)", - "KernelEventBaseSchema (const)", - "KernelReadyEvent (type)", - "KernelReadyEventSchema (const)", "KernelSecurityPolicy (type)", "KernelSecurityPolicySchema (const)", "KernelSecurityScanResult (type)", "KernelSecurityScanResultSchema (const)", "KernelSecurityVulnerability (type)", "KernelSecurityVulnerabilitySchema (const)", - "KernelShutdownEvent (type)", - "KernelShutdownEventSchema (const)", "LintableAuthoringCollection (interface)", "ListPackagesRequest (type)", "ListPackagesRequestSchema (const)", @@ -1676,9 +1665,6 @@ "PluginDynamicImportSchema (const)", "PluginEngines (type)", "PluginEnginesSchema (const)", - "PluginErrorEvent (type)", - "PluginErrorEventSchema (const)", - "PluginEventBaseSchema (const)", "PluginHealthCheck (type)", "PluginHealthCheckSchema (const)", "PluginHealthReport (type)", @@ -1696,9 +1682,6 @@ "PluginIntegritySchema (const)", "PluginInterface (type)", "PluginInterfaceSchema (const)", - "PluginLifecycleEventType (type)", - "PluginLifecyclePhaseEvent (type)", - "PluginLifecyclePhaseEventSchema (const)", "PluginLoadingConfig (type)", "PluginLoadingConfigSchema (const)", "PluginLoadingEvent (type)", @@ -1726,8 +1709,6 @@ "PluginQualityMetrics (type)", "PluginQualityMetricsInput (type)", "PluginQualityMetricsSchema (const)", - "PluginRegisteredEvent (type)", - "PluginRegisteredEventSchema (const)", "PluginRegistryEntry (type)", "PluginRegistryEntryInput (type)", "PluginRegistryEntrySchema (const)", @@ -1817,14 +1798,10 @@ "ServiceLeaderStrategySchema (const)", "ServiceMetadata (type)", "ServiceMetadataSchema (const)", - "ServiceRegisteredEvent (type)", - "ServiceRegisteredEventSchema (const)", "ServiceRegistryConfig (type)", "ServiceRegistryConfigInput (type)", "ServiceRegistryConfigSchema (const)", "ServiceScopeType (type)", - "ServiceUnregisteredEvent (type)", - "ServiceUnregisteredEventSchema (const)", "StartupOptions (type)", "StartupOptionsInput (type)", "StartupOptionsSchema (const)", @@ -3699,7 +3676,6 @@ "IStorageService (interface)", "ITeamGraphService (interface)", "ITenantRouter (interface)", - "ITypedEventEmitter (interface)", "IWorkflowService (interface)", "ImportObjectOpts (interface)", "ImportObjectResult (interface)", @@ -3724,6 +3700,7 @@ "KnowledgeReindexResult (interface)", "KnowledgeSearchOptions (interface)", "LLMAdapter (interface)", + "LifecycleEventName (type)", "ListExportJobsOptions (interface)", "ListShareLinksFilter (interface)", "LockAcquireOptions (interface)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 46fd85831d..9c94819925 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -4825,13 +4825,6 @@ "kernel/HealthStatus:healthy", "kernel/HealthStatus:message", "kernel/HealthStatus:timestamp", - "kernel/HookRegisteredEvent:handlerCount", - "kernel/HookRegisteredEvent:hookName", - "kernel/HookRegisteredEvent:timestamp", - "kernel/HookTriggeredEvent:args", - "kernel/HookTriggeredEvent:handlerCount", - "kernel/HookTriggeredEvent:hookName", - "kernel/HookTriggeredEvent:timestamp", "kernel/HotReloadConfig:afterReload", "kernel/HotReloadConfig:beforeReload", "kernel/HotReloadConfig:debounceDelay", @@ -4869,10 +4862,6 @@ "kernel/KernelContext:startTime", "kernel/KernelContext:version", "kernel/KernelContext:workspaceRoot", - "kernel/KernelEventBase:timestamp", - "kernel/KernelReadyEvent:duration", - "kernel/KernelReadyEvent:pluginCount", - "kernel/KernelReadyEvent:timestamp", "kernel/KernelSecurityPolicy:auditLog", "kernel/KernelSecurityPolicy:authentication", "kernel/KernelSecurityPolicy:cors", @@ -4904,8 +4893,6 @@ "kernel/KernelSecurityVulnerability:severity", "kernel/KernelSecurityVulnerability:title", "kernel/KernelSecurityVulnerability:workaround", - "kernel/KernelShutdownEvent:reason", - "kernel/KernelShutdownEvent:timestamp", "kernel/ListPackagesRequest:enabled", "kernel/ListPackagesRequest:status", "kernel/ListPackagesRequest:type", @@ -5224,14 +5211,6 @@ "kernel/PluginDynamicImport:webpackChunkName", "kernel/PluginEngines:platform", "kernel/PluginEngines:protocol", - "kernel/PluginErrorEvent:error", - "kernel/PluginErrorEvent:errorMessage", - "kernel/PluginErrorEvent:errorStack", - "kernel/PluginErrorEvent:phase", - "kernel/PluginErrorEvent:pluginName", - "kernel/PluginErrorEvent:timestamp", - "kernel/PluginEventBase:pluginName", - "kernel/PluginEventBase:timestamp", "kernel/PluginHealthCheck:autoRestart", "kernel/PluginHealthCheck:checkMethod", "kernel/PluginHealthCheck:failureThreshold", @@ -5274,10 +5253,6 @@ "kernel/PluginInterface:name", "kernel/PluginInterface:stability", "kernel/PluginInterface:version", - "kernel/PluginLifecyclePhaseEvent:duration", - "kernel/PluginLifecyclePhaseEvent:phase", - "kernel/PluginLifecyclePhaseEvent:pluginName", - "kernel/PluginLifecyclePhaseEvent:timestamp", "kernel/PluginLoadingConfig:caching", "kernel/PluginLoadingConfig:codeSplitting", "kernel/PluginLoadingConfig:dependencyResolution", @@ -5344,9 +5319,6 @@ "kernel/PluginQualityMetrics:documentationScore", "kernel/PluginQualityMetrics:securityScan", "kernel/PluginQualityMetrics:testCoverage", - "kernel/PluginRegisteredEvent:pluginName", - "kernel/PluginRegisteredEvent:timestamp", - "kernel/PluginRegisteredEvent:version", "kernel/PluginRegistryEntry:capabilities", "kernel/PluginRegistryEntry:category", "kernel/PluginRegistryEntry:compatibility", @@ -5570,16 +5542,11 @@ "kernel/ServiceMetadata:registeredAt", "kernel/ServiceMetadata:scope", "kernel/ServiceMetadata:type", - "kernel/ServiceRegisteredEvent:serviceName", - "kernel/ServiceRegisteredEvent:serviceType", - "kernel/ServiceRegisteredEvent:timestamp", "kernel/ServiceRegistryConfig:allowOverwrite", "kernel/ServiceRegistryConfig:enableLogging", "kernel/ServiceRegistryConfig:maxServices", "kernel/ServiceRegistryConfig:scopeTypes", "kernel/ServiceRegistryConfig:strictMode", - "kernel/ServiceUnregisteredEvent:serviceName", - "kernel/ServiceUnregisteredEvent:timestamp", "kernel/StartupOptions:context", "kernel/StartupOptions:healthCheck", "kernel/StartupOptions:parallel", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 035ad4188d..266ead7820 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -984,7 +984,6 @@ "kernel/EventMessageQueueConfig", "kernel/EventMetadata", "kernel/EventPersistence", - "kernel/EventPhase", "kernel/EventPriority", "kernel/EventQueueConfig", "kernel/EventReplayConfig", @@ -1000,19 +999,14 @@ "kernel/GetPackageResponse", "kernel/GracefulDegradation", "kernel/HealthStatus", - "kernel/HookRegisteredEvent", - "kernel/HookTriggeredEvent", "kernel/HotReloadConfig", "kernel/InstallPackageRequest", "kernel/InstallPackageResponse", "kernel/InstalledPackage", "kernel/KernelContext", - "kernel/KernelEventBase", - "kernel/KernelReadyEvent", "kernel/KernelSecurityPolicy", "kernel/KernelSecurityScanResult", "kernel/KernelSecurityVulnerability", - "kernel/KernelShutdownEvent", "kernel/ListPackagesRequest", "kernel/ListPackagesResponse", "kernel/Manifest", @@ -1077,8 +1071,6 @@ "kernel/PluginDependencyResolutionResult", "kernel/PluginDynamicImport", "kernel/PluginEngines", - "kernel/PluginErrorEvent", - "kernel/PluginEventBase", "kernel/PluginHealthCheck", "kernel/PluginHealthReport", "kernel/PluginHealthStatus", @@ -1087,8 +1079,6 @@ "kernel/PluginInstallConfig", "kernel/PluginIntegrity", "kernel/PluginInterface", - "kernel/PluginLifecycleEventType", - "kernel/PluginLifecyclePhaseEvent", "kernel/PluginLoadingConfig", "kernel/PluginLoadingEvent", "kernel/PluginLoadingState", @@ -1102,7 +1092,6 @@ "kernel/PluginPreloadConfig", "kernel/PluginProvenance", "kernel/PluginQualityMetrics", - "kernel/PluginRegisteredEvent", "kernel/PluginRegistryEntry", "kernel/PluginRuntime", "kernel/PluginSandboxing", @@ -1143,10 +1132,8 @@ "kernel/ServiceFactoryRegistration", "kernel/ServiceLeaderStrategy", "kernel/ServiceMetadata", - "kernel/ServiceRegisteredEvent", "kernel/ServiceRegistryConfig", "kernel/ServiceScopeType", - "kernel/ServiceUnregisteredEvent", "kernel/StartupOptions", "kernel/StartupOrchestrationResult", "kernel/TenantRuntimeContext", diff --git a/packages/spec/scripts/build-skill-references.ts b/packages/spec/scripts/build-skill-references.ts index 520e90cdfa..ba78360474 100644 --- a/packages/spec/scripts/build-skill-references.ts +++ b/packages/spec/scripts/build-skill-references.ts @@ -110,7 +110,9 @@ const SKILL_MAP: Record = { 'kernel/plugin.zod.ts', 'kernel/context.zod.ts', 'kernel/service-registry.zod.ts', - 'kernel/plugin-lifecycle-events.zod.ts', + // Lifecycle events: no Zod surface — the registry is the + // IPluginLifecycleEvents interface (src/contracts/plugin-lifecycle-events.ts), + // covered by the skill's hand-written references/plugin-hooks.md. 'kernel/plugin-capability.zod.ts', 'kernel/plugin-loading.zod.ts', 'kernel/metadata-plugin.zod.ts', diff --git a/packages/spec/src/contracts/plugin-lifecycle-events.test.ts b/packages/spec/src/contracts/plugin-lifecycle-events.test.ts index e2a880b5e6..2b2a1ebb35 100644 --- a/packages/spec/src/contracts/plugin-lifecycle-events.test.ts +++ b/packages/spec/src/contracts/plugin-lifecycle-events.test.ts @@ -1,137 +1,90 @@ import { describe, it, expect } from 'vitest'; -import type { IPluginLifecycleEvents, ITypedEventEmitter } from './plugin-lifecycle-events'; - -describe('Plugin Lifecycle Events Contract', () => { - describe('IPluginLifecycleEvents interface', () => { - it('should define all kernel event types', () => { - // Compile-time check: verify the event map type is correctly shaped - const events: Record, any> = { - 'kernel:ready': [], - 'kernel:bootstrapped': [], - 'kernel:listening': [], - 'kernel:shutdown': [], - 'kernel:before-init': [], - 'kernel:after-init': [150], - }; - - expect(events['kernel:ready']).toEqual([]); - expect(events['kernel:after-init']).toEqual([150]); - }); - - it('should define all plugin event types', () => { - const events: Record, any> = { - 'plugin:registered': ['my-plugin'], - 'plugin:before-init': ['my-plugin'], - 'plugin:init': ['my-plugin'], - 'plugin:after-init': ['my-plugin', 50], - 'plugin:before-start': ['my-plugin'], - 'plugin:started': ['my-plugin', 100], - 'plugin:after-start': ['my-plugin', 100], - 'plugin:before-destroy': ['my-plugin'], - 'plugin:destroyed': ['my-plugin'], - 'plugin:after-destroy': ['my-plugin', 25], - 'plugin:error': ['my-plugin', new Error('fail'), 'init'], - }; - - expect(events['plugin:registered']).toEqual(['my-plugin']); - expect(events['plugin:error'][2]).toBe('init'); +import type { IPluginLifecycleEvents, LifecycleEventName } from './plugin-lifecycle-events'; + +/** + * Pins the lifecycle-event registry to the fire-site inventory. + * + * Every name below has a real emitter (listed next to it). If you add a key + * to `IPluginLifecycleEvents`, add it here WITH its fire site; if you remove + * one, remove it here too. A name with no emitter does not belong in the + * registry — that is the disease the #4212 retirement cured (seventeen + * declared-never-fired names, an unused typed-emitter interface, and a dead + * parallel Zod schema file), and this pin is what keeps it cured. + */ +const FIRED_EVENTS = [ + 'kernel:ready', // core/src/kernel.ts, core/src/lite-kernel.ts + 'kernel:bootstrapped', // core/src/kernel.ts, core/src/lite-kernel.ts + 'kernel:listening', // core/src/kernel.ts, core/src/lite-kernel.ts + 'kernel:shutdown', // core/src/kernel.ts, core/src/lite-kernel.ts + 'app:seeded', // runtime/src/app-plugin.ts (inline seeder) + 'metadata:reloaded', // metadata/src/plugin.ts (artifact watcher) + 'external.schema.drift', // runtime/src/external-validation-plugin.ts + 'ai:routes', // cloud AI service plugin (out-of-repo); listener in runtime/src/dispatcher-plugin.ts + 'auth:configure', // plugins/plugin-auth/src/auth-plugin.ts + // The `{service}:ready` convention — payload is the live service instance. + 'mcp:ready', // mcp/src/plugin.ts + 'automation:ready', // services/service-automation/src/plugin.ts + 'analytics:ready', // services/service-analytics/src/plugin.ts + 'external-datasource:ready', // services/service-datasource/src/plugin.ts + 'datasource-admin:ready', // services/service-datasource/src/datasource-admin-plugin.ts +] as const; + +type FiredEvent = (typeof FIRED_EVENTS)[number]; +type AssertNever = T; + +// Two-way exhaustiveness, checked at compile time: the test file fails to +// build if the registry and this inventory drift apart in either direction. +type _EveryFiredNameIsDeclared = AssertNever>; +type _EveryDeclaredNameFires = AssertNever>; + +describe('lifecycle-event registry', () => { + it('declares exactly the events that fire', () => { + expect([...FIRED_EVENTS].sort()).toEqual( + [ + 'ai:routes', + 'analytics:ready', + 'app:seeded', + 'auth:configure', + 'automation:ready', + 'datasource-admin:ready', + 'external-datasource:ready', + 'external.schema.drift', + 'kernel:bootstrapped', + 'kernel:listening', + 'kernel:ready', + 'kernel:shutdown', + 'mcp:ready', + 'metadata:reloaded', + ].sort(), + ); }); - it('should define service and hook event types', () => { - const events: Record, any> = { - 'service:registered': ['database'], - 'service:unregistered': ['cache'], - 'hook:registered': ['beforeSave', 3], - 'hook:triggered': ['beforeSave', [{ id: 1 }]], - }; - - expect(events['service:registered']).toEqual(['database']); - expect(events['hook:registered']).toEqual(['beforeSave', 3]); - }); - }); - - describe('ITypedEventEmitter interface', () => { - it('should allow a minimal implementation with on/off/emit', () => { - const handlers = new Map(); - - const emitter: ITypedEventEmitter = { - on(event, handler) { - const list = handlers.get(event as string) || []; - list.push(handler as Function); - handlers.set(event as string, list); - }, - off(event, handler) { - const list = handlers.get(event as string) || []; - const idx = list.indexOf(handler as Function); - if (idx >= 0) list.splice(idx, 1); - }, - async emit(event, ...args) { - const list = handlers.get(event as string) || []; - for (const h of list) { - await h(...args); - } - }, - }; - - expect(typeof emitter.on).toBe('function'); - expect(typeof emitter.off).toBe('function'); - expect(typeof emitter.emit).toBe('function'); - }); - - it('should support registering and emitting typed events', async () => { - const received: string[] = []; - const handlers = new Map(); - - const emitter: ITypedEventEmitter = { - on(event, handler) { - const list = handlers.get(event as string) || []; - list.push(handler as Function); - handlers.set(event as string, list); - }, - off(event, handler) { - const list = handlers.get(event as string) || []; - const idx = list.indexOf(handler as Function); - if (idx >= 0) list.splice(idx, 1); - }, - async emit(event, ...args) { - const list = handlers.get(event as string) || []; - for (const h of list) { - await h(...args); - } - }, - }; - - emitter.on('plugin:registered', (pluginName: string) => { - received.push(pluginName); - }); - - await emitter.emit('plugin:registered', 'auth-plugin'); - - expect(received).toEqual(['auth-plugin']); - }); - - it('should allow optional once, listenerCount, and removeAllListeners', () => { - const emitter: ITypedEventEmitter = { - on: () => {}, - off: () => {}, - emit: async () => {}, - once: () => {}, - listenerCount: () => 0, - removeAllListeners: () => {}, - }; - - expect(emitter.once).toBeDefined(); - expect(emitter.listenerCount).toBeDefined(); - expect(emitter.removeAllListeners).toBeDefined(); + it('payload tuples match the fire sites', () => { + // Compile-time assignability against the shapes each emitter passes. + const kernelReady: IPluginLifecycleEvents['kernel:ready'] = []; + const seeded: IPluginLifecycleEvents['app:seeded'] = [ + { appId: 'crm', overBudget: false }, + ]; + // `metadata` is optional: the artifact watcher includes it, but the + // documented contract lets announcers omit it. + const reloaded: IPluginLifecycleEvents['metadata:reloaded'] = [ + { changed: ['flow/ticket_closed'] }, + ]; + const drift: IPluginLifecycleEvents['external.schema.drift'] = [ + { datasource: 'erp', object: 'invoice', diffs: [] }, + ]; + const authConfigure: IPluginLifecycleEvents['auth:configure'] = [{}, {}]; + const mcpReady: IPluginLifecycleEvents['mcp:ready'] = [{}]; + const automationReady: IPluginLifecycleEvents['automation:ready'] = [{}]; + const aiRoutes: IPluginLifecycleEvents['ai:routes'] = [[]]; + + expect(kernelReady).toEqual([]); + expect(seeded[0].overBudget).toBe(false); + expect(reloaded[0].changed).toEqual(['flow/ticket_closed']); + expect(drift[0].object).toBe('invoice'); + expect(authConfigure).toHaveLength(2); + expect(mcpReady).toHaveLength(1); + expect(automationReady).toHaveLength(1); + expect(aiRoutes[0]).toEqual([]); }); - }); }); diff --git a/packages/spec/src/contracts/plugin-lifecycle-events.ts b/packages/spec/src/contracts/plugin-lifecycle-events.ts index f8faee4aeb..b6fa701887 100644 --- a/packages/spec/src/contracts/plugin-lifecycle-events.ts +++ b/packages/spec/src/contracts/plugin-lifecycle-events.ts @@ -1,20 +1,45 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * IPluginLifecycleEvents - Typed Plugin Lifecycle Events - * - * Type-safe event definitions for plugin and kernel lifecycle. - * Provides strong typing for event emitters and listeners. - * - * This replaces the generic Map approach with typed events. - */ - -/** - * Plugin lifecycle event types and their payloads + * IPluginLifecycleEvents — the registry of kernel-bus events that actually fire. + * + * Every key in this interface is an event some shipping code path triggers via + * `ctx.trigger(...)` (or `triggerHook` in the lite kernel), with its payload + * tuple as observed at the fire site. The registry is *enforced*, not + * aspirational, in two ways: + * + * - {@link LifecycleEventName} feeds `IPluginContext.hook` / `trigger` + * autocomplete in `@objectstack/core` (as `LifecycleEventName | (string & {})`, + * so custom cross-plugin events remain legal — the bus is open by design). + * - `plugin-lifecycle-events.test.ts` pins the key set to the fire-site + * inventory. Adding a key here without a real emitter — or removing one that + * still fires — fails that test with a pointer back to this doc. + * + * ## Retired names (ADR-0049 enforce-or-remove) + * + * An earlier revision of this file also declared a typed-event system that was + * never built: `kernel:before-init`, `kernel:after-init`, `plugin:registered`, + * `plugin:before-init`, `plugin:init`, `plugin:after-init`, + * `plugin:before-start`, `plugin:started`, `plugin:after-start`, + * `plugin:before-destroy`, `plugin:destroyed`, `plugin:after-destroy`, + * `plugin:error`, `service:registered`, `service:unregistered`, + * `hook:registered`, `hook:triggered` — seventeen names with no emitter and no + * listener anywhere — plus an `ITypedEventEmitter` interface and a parallel + * set of Zod payload schemas (`kernel/plugin-lifecycle-events.zod.ts`) with + * zero consumers. All of it is retired, same playbook as the PluginSchema + * lifecycle-hook retirement (#4212): a declared-but-dead contract reads as a + * promise and silently swallows anyone who codes against it. If per-plugin + * phase events become real, re-add each name together with its emitter and a + * fire-site pointer, and extend the pinning test. */ export interface IPluginLifecycleEvents { /** - * Emitted when kernel is ready (all plugins initialized) + * Emitted when kernel is ready (all plugins initialized). + * + * Fired by `ObjectKernel.start()` and `LiteKernel.bootstrap()` after every + * plugin's `start()` has completed. Handlers run sequentially in plugin + * registration order — see `kernel:bootstrapped` for the ordering caveat. + * * Payload: [] */ 'kernel:ready': []; @@ -65,6 +90,13 @@ export interface IPluginLifecycleEvents { */ 'kernel:listening': []; + /** + * Emitted when kernel is shutting down, before plugins are destroyed. + * + * Payload: [] + */ + 'kernel:shutdown': []; + /** * Emitted by the app plugin when an app's inline seed attempt has settled * — success, partial (dropped records), or fallback insert. Single-tenant @@ -87,169 +119,113 @@ export interface IPluginLifecycleEvents { 'app:seeded': [payload: { appId: string; overBudget: boolean }]; /** - * Emitted when kernel is shutting down - * Payload: [] - */ - 'kernel:shutdown': []; - - /** - * Emitted before kernel initialization starts - * Payload: [] - */ - 'kernel:before-init': []; - - /** - * Emitted after kernel initialization completes - * Payload: [duration: number (milliseconds)] - */ - 'kernel:after-init': [duration: number]; - - /** - * Emitted when a plugin is registered - * Payload: [pluginName: string] - */ - 'plugin:registered': [pluginName: string]; - - /** - * Emitted before a plugin's init method is called - * Payload: [pluginName: string] - */ - 'plugin:before-init': [pluginName: string]; - - /** - * Emitted when a plugin has been initialized - * Payload: [pluginName: string] - */ - 'plugin:init': [pluginName: string]; - - /** - * Emitted after a plugin's init method completes - * Payload: [pluginName: string, duration: number (milliseconds)] - */ - 'plugin:after-init': [pluginName: string, duration: number]; - - /** - * Emitted before a plugin's start method is called - * Payload: [pluginName: string] - */ - 'plugin:before-start': [pluginName: string]; - - /** - * Emitted when a plugin has started successfully - * Payload: [pluginName: string, duration: number (milliseconds)] - */ - 'plugin:started': [pluginName: string, duration: number]; - - /** - * Emitted after a plugin's start method completes - * Payload: [pluginName: string, duration: number (milliseconds)] + * Emitted by the metadata plugin's artifact watcher after a changed + * `dist/objectstack.json` has been re-parsed and re-registered mid-run. + * + * `changed` entries are `'{type}/{name}'` strings (e.g. + * `'flow/ticket_closed'`). `metadata`, when present, carries the freshly + * parsed artifact collections so subscribers can consume the ones that + * never reach the MetadataManager (seed datasets under `data` have no + * `name`). The app plugin uses it to load seeds for objects that appear + * mid-run. The collections shape is owned by `@objectstack/metadata`; the + * spec keeps it opaque. + * + * Payload: [{ changed, metadata? }] */ - 'plugin:after-start': [pluginName: string, duration: number]; - + 'metadata:reloaded': [payload: { changed: string[]; metadata?: unknown }]; + /** - * Emitted before a plugin's destroy method is called - * Payload: [pluginName: string] + * Emitted by `@objectstack/plugin-auth` while assembling its better-auth + * config, BEFORE the auth instance is created. The open extension point + * for packages that contribute auth providers (enterprise SSO, hosted + * control-plane SSO, …) without forking the auth plugin: handlers mutate + * the draft config in place. Both payload types are owned by plugin-auth; + * the spec keeps them opaque. + * + * Payload: [authConfig, ctx] */ - 'plugin:before-destroy': [pluginName: string]; - + 'auth:configure': [authConfig: unknown, ctx: unknown]; + + // ── The `{service}:ready` convention ───────────────────────────────── + // Service plugins announce readiness as `{service}:ready`, passing the + // live service instance so sibling plugins can extend it. Each instance + // type is owned by its package; the spec keeps them opaque. A new service + // adopting the convention adds its concrete name here (custom names are + // legal without registration — the bus is open — but a registered name + // autocompletes and is pinned to its fire site by the registry test). + /** - * Emitted when a plugin has been destroyed - * Payload: [pluginName: string] + * Emitted by `@objectstack/mcp` once its server runtime is constructed + * and tools are registered. Payload: the live `MCPServerRuntime`. + * + * Payload: [runtime] */ - 'plugin:destroyed': [pluginName: string]; - + 'mcp:ready': [runtime: unknown]; + /** - * Emitted after a plugin's destroy method completes - * Payload: [pluginName: string, duration: number (milliseconds)] + * Emitted by `@objectstack/service-automation` at start, once the flow + * engine is ready. Payload: the live automation engine. + * + * Payload: [engine] */ - 'plugin:after-destroy': [pluginName: string, duration: number]; - + 'automation:ready': [engine: unknown]; + /** - * Emitted when a plugin encounters an error - * Payload: [pluginName: string, error: Error, phase: 'init' | 'start' | 'destroy'] + * Emitted by `@objectstack/service-analytics` at start. Payload: the live + * analytics service. + * + * Payload: [service] */ - 'plugin:error': [pluginName: string, error: Error, phase: 'init' | 'start' | 'destroy']; - + 'analytics:ready': [service: unknown]; + /** - * Emitted when a service is registered - * Payload: [serviceName: string] + * Emitted by `@objectstack/service-datasource` at start. Payload: the + * live external-datasource service. + * + * Payload: [service] */ - 'service:registered': [serviceName: string]; - + 'external-datasource:ready': [service: unknown]; + /** - * Emitted when a service is unregistered - * Payload: [serviceName: string] + * Emitted by `@objectstack/service-datasource`'s admin plugin at start. + * Payload: the live datasource-admin service. + * + * Payload: [service] */ - 'service:unregistered': [serviceName: string]; - + 'datasource-admin:ready': [service: unknown]; + /** - * Emitted when a hook is registered - * Payload: [hookName: string, handlerCount: number] + * Emitted by the external-validation plugin's background drift checker + * (ADR-0015 §5.2), one event per federated object whose remote schema no + * longer matches its declared shape. Consumed by audit / notification + * services. `diffs` entries are `SchemaDiffEntry` values owned by + * `@objectstack/runtime`; the spec keeps them opaque. + * + * Payload: [{ datasource, object, diffs }] */ - 'hook:registered': [hookName: string, handlerCount: number]; - + 'external.schema.drift': [ + event: { datasource: string; object: string; diffs: unknown[] }, + ]; + /** - * Emitted when a hook is triggered - * Payload: [hookName: string, args: any[]] + * Emitted by the cloud AI service plugin (out-of-repo emitter) with the + * dynamic `RouteDefinition[]` it wants mounted; the dispatcher plugin + * listens and mounts each route on the HTTP server (environment-scoped + * variants included). Because plugin start order is not guaranteed, the + * emitter also caches the routes on the kernel as `__aiRoutes` for the + * dispatcher to recover when it starts late — see + * `DispatcherPlugin` for the recovery half of the protocol. + * + * Payload: [routes] */ - 'hook:triggered': [hookName: string, args: any[]]; + 'ai:routes': [routes: unknown[]]; } /** - * Type-safe event emitter interface - * Provides compile-time type checking for event names and payloads + * Union of every kernel-bus event name that actually fires. + * + * `IPluginContext.hook` / `trigger` in `@objectstack/core` accept + * `LifecycleEventName | (string & {})`: known names autocomplete, custom + * cross-plugin event names stay legal. */ -export interface ITypedEventEmitter> { - /** - * Register an event listener - * @param event - Event name (type-checked) - * @param handler - Event handler (type-checked against event payload) - */ - on( - event: K, - handler: (...args: Events[K]) => void | Promise - ): void; - - /** - * Unregister an event listener - * @param event - Event name (type-checked) - * @param handler - Event handler to remove - */ - off( - event: K, - handler: (...args: Events[K]) => void | Promise - ): void; - - /** - * Emit an event with type-checked payload - * @param event - Event name (type-checked) - * @param args - Event payload (type-checked) - */ - emit( - event: K, - ...args: Events[K] - ): Promise; - - /** - * Register a one-time event listener - * @param event - Event name (type-checked) - * @param handler - Event handler (type-checked against event payload) - */ - once?( - event: K, - handler: (...args: Events[K]) => void | Promise - ): void; - - /** - * Get the number of listeners for an event - * @param event - Event name - * @returns Number of registered listeners - */ - listenerCount?(event: K): number; - - /** - * Remove all listeners for an event (or all events if not specified) - * @param event - Optional event name - */ - removeAllListeners?(event?: K): void; -} +export type LifecycleEventName = keyof IPluginLifecycleEvents & string; diff --git a/packages/spec/src/kernel/index.ts b/packages/spec/src/kernel/index.ts index ac59265dff..06bbf7bbda 100644 --- a/packages/spec/src/kernel/index.ts +++ b/packages/spec/src/kernel/index.ts @@ -36,7 +36,6 @@ export * from './package-registry.zod'; export * from './package-upgrade.zod'; export * from './plugin-capability.zod'; export * from './plugin-lifecycle-advanced.zod'; -export * from './plugin-lifecycle-events.zod'; export * from './plugin-loading.zod'; export * from './plugin-runtime.zod'; export * from './plugin-security-advanced.zod'; diff --git a/packages/spec/src/kernel/plugin-lifecycle-events.test.ts b/packages/spec/src/kernel/plugin-lifecycle-events.test.ts deleted file mode 100644 index a4db596569..0000000000 --- a/packages/spec/src/kernel/plugin-lifecycle-events.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - EventPhaseSchema, - PluginRegisteredEventSchema, - PluginLifecyclePhaseEventSchema, - PluginErrorEventSchema, - ServiceRegisteredEventSchema, - ServiceUnregisteredEventSchema, - HookRegisteredEventSchema, - HookTriggeredEventSchema, - KernelReadyEventSchema, - KernelShutdownEventSchema, - PluginLifecycleEventType, -} from './plugin-lifecycle-events.zod'; - -describe('Plugin Lifecycle Events Protocol', () => { - describe('EventPhaseSchema', () => { - it('should validate valid phases', () => { - expect(EventPhaseSchema.safeParse('init').success).toBe(true); - expect(EventPhaseSchema.safeParse('start').success).toBe(true); - expect(EventPhaseSchema.safeParse('destroy').success).toBe(true); - }); - - it('should reject invalid phases', () => { - expect(EventPhaseSchema.safeParse('unknown').success).toBe(false); - }); - }); - - describe('PluginRegisteredEventSchema', () => { - it('should validate plugin registered event', () => { - const event = { - pluginName: 'crm-plugin', - timestamp: Date.now(), - version: '1.0.0', - }; - - const result = PluginRegisteredEventSchema.safeParse(event); - expect(result.success).toBe(true); - }); - - it('should validate event without version', () => { - const event = { - pluginName: 'crm-plugin', - timestamp: Date.now(), - }; - - const result = PluginRegisteredEventSchema.safeParse(event); - expect(result.success).toBe(true); - }); - }); - - describe('PluginLifecyclePhaseEventSchema', () => { - it('should validate lifecycle phase event', () => { - const event = { - pluginName: 'crm-plugin', - timestamp: Date.now(), - duration: 1250, - phase: 'init' as const, - }; - - const result = PluginLifecyclePhaseEventSchema.safeParse(event); - expect(result.success).toBe(true); - }); - }); - - describe('PluginErrorEventSchema', () => { - it('should validate plugin error event', () => { - const event = { - pluginName: 'failing-plugin', - timestamp: Date.now(), - error: { name: 'Error', message: 'Connection failed' }, - phase: 'start' as const, - errorMessage: 'Connection failed', - }; - - const result = PluginErrorEventSchema.safeParse(event); - expect(result.success).toBe(true); - }); - - it('should require phase field', () => { - const event = { - pluginName: 'failing-plugin', - timestamp: Date.now(), - error: { name: 'Error', message: 'Connection failed' }, - // missing phase - }; - - const result = PluginErrorEventSchema.safeParse(event); - expect(result.success).toBe(false); - }); - }); - - describe('ServiceRegisteredEventSchema', () => { - it('should validate service registered event', () => { - const event = { - serviceName: 'database', - timestamp: Date.now(), - serviceType: 'IDataEngine', - }; - - const result = ServiceRegisteredEventSchema.safeParse(event); - expect(result.success).toBe(true); - }); - }); - - describe('ServiceUnregisteredEventSchema', () => { - it('should validate service unregistered event', () => { - const event = { - serviceName: 'database', - timestamp: Date.now(), - }; - - const result = ServiceUnregisteredEventSchema.safeParse(event); - expect(result.success).toBe(true); - }); - }); - - describe('HookRegisteredEventSchema', () => { - it('should validate hook registered event', () => { - const event = { - hookName: 'data.beforeInsert', - timestamp: Date.now(), - handlerCount: 3, - }; - - const result = HookRegisteredEventSchema.safeParse(event); - expect(result.success).toBe(true); - }); - - it('should reject negative handler count', () => { - const event = { - hookName: 'data.beforeInsert', - timestamp: Date.now(), - handlerCount: -1, - }; - - const result = HookRegisteredEventSchema.safeParse(event); - expect(result.success).toBe(false); - }); - }); - - describe('HookTriggeredEventSchema', () => { - it('should validate hook triggered event', () => { - const event = { - hookName: 'data.beforeInsert', - timestamp: Date.now(), - args: [{ object: 'customer', data: { name: 'Test' } }], - handlerCount: 3, - }; - - const result = HookTriggeredEventSchema.safeParse(event); - expect(result.success).toBe(true); - }); - }); - - describe('KernelReadyEventSchema', () => { - it('should validate kernel ready event', () => { - const event = { - timestamp: Date.now(), - duration: 5400, - pluginCount: 12, - }; - - const result = KernelReadyEventSchema.safeParse(event); - expect(result.success).toBe(true); - }); - - it('should validate minimal kernel ready event', () => { - const event = { - timestamp: Date.now(), - }; - - const result = KernelReadyEventSchema.safeParse(event); - expect(result.success).toBe(true); - }); - }); - - describe('KernelShutdownEventSchema', () => { - it('should validate kernel shutdown event', () => { - const event = { - timestamp: Date.now(), - reason: 'SIGTERM received', - }; - - const result = KernelShutdownEventSchema.safeParse(event); - expect(result.success).toBe(true); - }); - }); - - describe('PluginLifecycleEventType', () => { - it('should validate all event types', () => { - const eventTypes = [ - 'kernel:ready', - 'kernel:bootstrapped', - 'kernel:listening', - 'kernel:shutdown', - 'kernel:before-init', - 'kernel:after-init', - 'plugin:registered', - 'plugin:before-init', - 'plugin:init', - 'plugin:after-init', - 'plugin:before-start', - 'plugin:started', - 'plugin:after-start', - 'plugin:before-destroy', - 'plugin:destroyed', - 'plugin:after-destroy', - 'plugin:error', - 'service:registered', - 'service:unregistered', - 'hook:registered', - 'hook:triggered', - ]; - - eventTypes.forEach(eventType => { - const result = PluginLifecycleEventType.safeParse(eventType); - expect(result.success).toBe(true); - }); - }); - - it('should reject invalid event type', () => { - const result = PluginLifecycleEventType.safeParse('invalid:event'); - expect(result.success).toBe(false); - }); - }); -}); diff --git a/packages/spec/src/kernel/plugin-lifecycle-events.zod.ts b/packages/spec/src/kernel/plugin-lifecycle-events.zod.ts deleted file mode 100644 index 883732c4cd..0000000000 --- a/packages/spec/src/kernel/plugin-lifecycle-events.zod.ts +++ /dev/null @@ -1,346 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; - -/** - * Plugin Lifecycle Events Protocol - * - * Zod schemas for plugin lifecycle event data structures. - * These schemas align with the IPluginLifecycleEvents contract interface. - * - * Following ObjectStack "Zod First" principle - all data structures - * must have Zod schemas for runtime validation and JSON Schema generation. - */ - -// ============================================================================ -// Event Payload Schemas -// ============================================================================ - -/** - * Event Phase Enum - * Lifecycle phase where an error occurred - */ -import { lazySchema } from '../shared/lazy-schema'; -export const EventPhaseSchema = lazySchema(() => z.enum(['init', 'start', 'destroy']) - .describe('Plugin lifecycle phase')); - -export type EventPhase = z.infer; - -/** - * Plugin Event Base Schema - * Common fields for all plugin events - */ -export const PluginEventBaseSchema = lazySchema(() => z.object({ - /** - * Plugin name - */ - pluginName: z.string().describe('Name of the plugin'), - - /** - * Event timestamp (Unix milliseconds) - */ - timestamp: z.number().int().describe('Unix timestamp in milliseconds when event occurred'), -})); - -/** - * Plugin Registered Event Schema - * - * @example - * { - * "pluginName": "crm-plugin", - * "timestamp": 1706659200000, - * "version": "1.0.0" - * } - */ -export const PluginRegisteredEventSchema = lazySchema(() => PluginEventBaseSchema.extend({ - /** - * Plugin version (optional) - */ - version: z.string().optional().describe('Plugin version'), -})); - -export type PluginRegisteredEvent = z.infer; - -/** - * Plugin Lifecycle Phase Event Schema - * For init, start, destroy phases - * - * @example - * { - * "pluginName": "crm-plugin", - * "timestamp": 1706659200000, - * "duration": 1250, - * "phase": "init" - * } - */ -export const PluginLifecyclePhaseEventSchema = lazySchema(() => PluginEventBaseSchema.extend({ - /** - * Duration of the phase (milliseconds) - */ - duration: z.number().min(0).optional().describe('Duration of the lifecycle phase in milliseconds'), - - /** - * Lifecycle phase - */ - phase: EventPhaseSchema.optional().describe('Lifecycle phase'), -})); - -export type PluginLifecyclePhaseEvent = z.infer; - -/** - * Plugin Error Event Schema - * When a plugin encounters an error - * - * @example - * { - * "pluginName": "crm-plugin", - * "timestamp": 1706659200000, - * "error": Error("Connection failed"), - * "phase": "start", - * "errorMessage": "Connection failed", - * "errorStack": "Error: Connection failed\n at ..." - * } - */ -export const PluginErrorEventSchema = lazySchema(() => PluginEventBaseSchema.extend({ - /** - * Error object - */ - error: z.object({ - name: z.string().describe('Error class name'), - message: z.string().describe('Error message'), - stack: z.string().optional().describe('Stack trace'), - code: z.string().optional().describe('Error code'), - }).describe('Serializable error representation'), - - /** - * Lifecycle phase where error occurred - */ - phase: EventPhaseSchema.describe('Lifecycle phase where error occurred'), - - /** - * Error message (for serialization) - */ - errorMessage: z.string().optional().describe('Error message'), - - /** - * Error stack trace (for debugging) - */ - errorStack: z.string().optional().describe('Error stack trace'), -})); - -export type PluginErrorEvent = z.infer; - -// ============================================================================ -// Service Event Schemas -// ============================================================================ - -/** - * Service Registered Event Schema - * - * @example - * { - * "serviceName": "database", - * "timestamp": 1706659200000, - * "serviceType": "IDataEngine" - * } - */ -export const ServiceRegisteredEventSchema = lazySchema(() => z.object({ - /** - * Service name - */ - serviceName: z.string().describe('Name of the registered service'), - - /** - * Event timestamp (Unix milliseconds) - */ - timestamp: z.number().int().describe('Unix timestamp in milliseconds'), - - /** - * Service type (optional) - */ - serviceType: z.string().optional().describe('Type or interface name of the service'), -})); - -export type ServiceRegisteredEvent = z.infer; - -/** - * Service Unregistered Event Schema - * - * @example - * { - * "serviceName": "database", - * "timestamp": 1706659200000 - * } - */ -export const ServiceUnregisteredEventSchema = lazySchema(() => z.object({ - /** - * Service name - */ - serviceName: z.string().describe('Name of the unregistered service'), - - /** - * Event timestamp (Unix milliseconds) - */ - timestamp: z.number().int().describe('Unix timestamp in milliseconds'), -})); - -export type ServiceUnregisteredEvent = z.infer; - -// ============================================================================ -// Hook Event Schemas -// ============================================================================ - -/** - * Hook Registered Event Schema - * - * @example - * { - * "hookName": "data.beforeInsert", - * "timestamp": 1706659200000, - * "handlerCount": 3 - * } - */ -export const HookRegisteredEventSchema = lazySchema(() => z.object({ - /** - * Hook name - */ - hookName: z.string().describe('Name of the hook'), - - /** - * Event timestamp (Unix milliseconds) - */ - timestamp: z.number().int().describe('Unix timestamp in milliseconds'), - - /** - * Number of handlers registered for this hook - */ - handlerCount: z.number().int().min(0).describe('Number of handlers registered for this hook'), -})); - -export type HookRegisteredEvent = z.infer; - -/** - * Hook Triggered Event Schema - * - * @example - * { - * "hookName": "data.beforeInsert", - * "timestamp": 1706659200000, - * "args": [{ "object": "customer", "data": {...} }], - * "handlerCount": 3 - * } - */ -export const HookTriggeredEventSchema = lazySchema(() => z.object({ - /** - * Hook name - */ - hookName: z.string().describe('Name of the hook'), - - /** - * Event timestamp (Unix milliseconds) - */ - timestamp: z.number().int().describe('Unix timestamp in milliseconds'), - - /** - * Arguments passed to the hook - */ - args: z.array(z.unknown()).describe('Arguments passed to the hook handlers'), - - /** - * Number of handlers that will handle this event - */ - handlerCount: z.number().int().min(0).optional().describe('Number of handlers that will handle this event'), -})); - -export type HookTriggeredEvent = z.infer; - -// ============================================================================ -// Kernel Event Schemas -// ============================================================================ - -/** - * Kernel Event Base Schema - * Common fields for kernel events - */ -export const KernelEventBaseSchema = lazySchema(() => z.object({ - /** - * Event timestamp (Unix milliseconds) - */ - timestamp: z.number().int().describe('Unix timestamp in milliseconds'), -})); - -/** - * Kernel Ready Event Schema - * - * @example - * { - * "timestamp": 1706659200000, - * "duration": 5400, - * "pluginCount": 12 - * } - */ -export const KernelReadyEventSchema = lazySchema(() => KernelEventBaseSchema.extend({ - /** - * Total initialization duration (milliseconds) - */ - duration: z.number().min(0).optional().describe('Total initialization duration in milliseconds'), - - /** - * Number of plugins initialized - */ - pluginCount: z.number().int().min(0).optional().describe('Number of plugins initialized'), -})); - -export type KernelReadyEvent = z.infer; - -/** - * Kernel Shutdown Event Schema - * - * @example - * { - * "timestamp": 1706659200000, - * "reason": "SIGTERM received" - * } - */ -export const KernelShutdownEventSchema = lazySchema(() => KernelEventBaseSchema.extend({ - /** - * Shutdown reason (optional) - */ - reason: z.string().optional().describe('Reason for kernel shutdown'), -})); - -export type KernelShutdownEvent = z.infer; - -// ============================================================================ -// Event Type Registry -// ============================================================================ - -/** - * Plugin Lifecycle Event Type Enum - * All possible plugin lifecycle event types - */ -export const PluginLifecycleEventType = z.enum([ - 'kernel:ready', - 'kernel:bootstrapped', - 'kernel:listening', - 'kernel:shutdown', - 'kernel:before-init', - 'kernel:after-init', - 'plugin:registered', - 'plugin:before-init', - 'plugin:init', - 'plugin:after-init', - 'plugin:before-start', - 'plugin:started', - 'plugin:after-start', - 'plugin:before-destroy', - 'plugin:destroyed', - 'plugin:after-destroy', - 'plugin:error', - 'service:registered', - 'service:unregistered', - 'hook:registered', - 'hook:triggered', -]).describe('Plugin lifecycle event type'); - -export type PluginLifecycleEventType = z.infer; diff --git a/skills/objectstack-platform/references/_index.md b/skills/objectstack-platform/references/_index.md index 60a957d474..bd86e85676 100644 --- a/skills/objectstack-platform/references/_index.md +++ b/skills/objectstack-platform/references/_index.md @@ -15,7 +15,6 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/kernel/manifest.zod.ts` — Structured permission grants requested by a plugin (ADR-0025 §3.2). - `node_modules/@objectstack/spec/src/kernel/metadata-plugin.zod.ts` — Metadata Plugin Protocol - `node_modules/@objectstack/spec/src/kernel/plugin-capability.zod.ts` — Plugin Capability Protocol -- `node_modules/@objectstack/spec/src/kernel/plugin-lifecycle-events.zod.ts` — Plugin Lifecycle Events Protocol - `node_modules/@objectstack/spec/src/kernel/plugin-loading.zod.ts` — Plugin Loading Protocol - `node_modules/@objectstack/spec/src/kernel/plugin.zod.ts` — Shared Plugin Types - `node_modules/@objectstack/spec/src/kernel/service-registry.zod.ts` — Service Registry Protocol From 8146630eec46419ac23ed9cebb8db7d5ec100070 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 18:16:58 +0000 Subject: [PATCH 2/2] chore: changeset for the lifecycle-event registry retirement Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh --- .../lifecycle-event-registry-enforced.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .changeset/lifecycle-event-registry-enforced.md diff --git a/.changeset/lifecycle-event-registry-enforced.md b/.changeset/lifecycle-event-registry-enforced.md new file mode 100644 index 0000000000..904a48951b --- /dev/null +++ b/.changeset/lifecycle-event-registry-enforced.md @@ -0,0 +1,64 @@ +--- +"@objectstack/spec": major +"@objectstack/core": patch +--- + +fix(spec)!: retire the never-built typed-event system; the lifecycle registry now lists the events that actually fire (#4212 follow-up) + +The lifecycle-event surface promised a typed-event system that was never +built, in three layers. `kernel/plugin-lifecycle-events.zod.ts` shipped ten +payload schemas (`PluginRegisteredEvent`, `PluginErrorEvent`, +`HookTriggeredEvent`, `KernelReadyEvent`, …) and a 21-name +`PluginLifecycleEventType` enum — zero consumers for every export, and the +enum was wrong in both directions: 17 names nothing fires, 10 real events +missing. `contracts/plugin-lifecycle-events.ts` declared the same 17 dead +names in `IPluginLifecycleEvents` next to 5 real ones, plus an +`ITypedEventEmitter` interface nothing implements. All of it read as a +promise; anyone who coded against it (hooking `plugin:started`, awaiting +`plugin:error`) registered a handler that could never fire, with no error +saying so — the same silent-drop shape as the #4212 lifecycle-hook family. + +Removed, with zero consumers verified repo-wide: + +- `kernel/plugin-lifecycle-events.zod.ts` and every export: `EventPhase`, + `PluginEventBase`, `PluginRegisteredEvent`, `PluginLifecyclePhaseEvent`, + `PluginErrorEvent`, `ServiceRegisteredEvent`, `ServiceUnregisteredEvent`, + `HookRegisteredEvent`, `HookTriggeredEvent`, `KernelEventBase`, + `KernelReadyEvent`, `KernelShutdownEvent`, `PluginLifecycleEventType` + (schemas and inferred types). +- `ITypedEventEmitter` from `contracts/plugin-lifecycle-events.ts`. +- The 17 never-fired names from `IPluginLifecycleEvents`. + +`IPluginLifecycleEvents` is now the registry of the **14 events with a real +emitter** — `kernel:{ready,bootstrapped,listening,shutdown}`, `app:seeded`, +`metadata:reloaded` (payload `metadata` now optional, matching the documented +contract), `external.schema.drift`, `ai:routes`, `auth:configure`, and the +`{service}:ready` convention family (`mcp`, `automation`, `analytics`, +`external-datasource`, `datasource-admin`) — each payload as observed at its +fire site. A new `LifecycleEventName` union types +`PluginContext.hook`/`trigger` in `@objectstack/core` as +`LifecycleEventName | (string & {})`: known names autocomplete, custom +cross-plugin names stay legal, existing callers compile unchanged. A pinning +test asserts two-way equality between the interface keys and the fire-site +inventory. + +FROM → TO: + +- `PluginLifecycleEventType` → `LifecycleEventName` (the union of names that + fire). There is no runtime enum; the bus is open by design. +- Event payload schemas (`KernelReadyEvent`, `PluginErrorEvent`, …) → the + payload tuples on `IPluginLifecycleEvents`. No wire format existed or + exists; payloads are in-process arguments. +- `ITypedEventEmitter` → `PluginContext.hook`/`trigger` (the emitter that + actually exists). +- Handlers for the 17 dead names → delete them; they never ran. For plugin + phase observation use the boot report (ADR-0084); for per-plugin errors the + kernel throws/logs at the failing phase. + +Plain deletion rather than `retiredKey()` tombstones, per the #4233 +precedent: these keys were never authorable — they described runtime event +payload records no config author can write, so the silent-strip class the +authorable-surface ratchet guards against is vacuous. Its baseline entries +and the `json-schema.manifest.json` keys are dropped deliberately in this PR. +No ADR-0087 conversion: no stack metadata names these types; there is nothing +for `os migrate meta` to rewrite.