diff --git a/.changeset/hot-reload-inert-state-strategies-retired.md b/.changeset/hot-reload-inert-state-strategies-retired.md new file mode 100644 index 0000000000..ccb3531d9d --- /dev/null +++ b/.changeset/hot-reload-inert-state-strategies-retired.md @@ -0,0 +1,86 @@ +--- +"@objectstack/spec": minor +"@objectstack/core": minor +--- + +fix(spec,core): `HotReloadConfig.stateStrategy` refuses the two values it never implemented; `distributedConfig` retired (#12340, ADR-0049) + + + +**BREAKING** accept-set narrowing + export removal, landing after the v17.0.0 +cut (the lockstep launch-window convention ships it as `minor`; the +prescription is registered under protocol major 18 — +`RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry +`hot-reload-inert-state-strategies-retired` — where `os migrate meta` users +will look). + +This is ADR-0049 applied one level INSIDE the library the 2026-08-25 #11825 +ruling deliberately kept. That ruling retired the authorable lifecycle-config +container and kept `HotReloadConfigSchema` as a host-driven library parameter +type; this change measures the kept vocabulary's own remainder and finds the +same defect in it. The keep itself stands — `HotReloadConfigSchema`, +`PluginStateSnapshotSchema` and the health vocabularies still export, and +`HotReloadManager` / `PluginHealthMonitor` are untouched. + +The `'disk'` and `'distributed'` arms of `PluginStateManager.saveState` both +wrote to the SAME in-memory `Map` as `'memory'` — the in-source comments said +"memory fallback" — and announced the substitution at DEBUG level only. A host +that asked for durable or cluster-replicated state got process-local memory +and no error: state that does not survive the restart it was configured to +survive. `distributedConfig` had ZERO readers anywhere, so an author could +name a Redis endpoint, a TTL and a replication factor and nothing ever opened +a connection. + +FROM → TO: + +- `stateStrategy: 'disk'` → `stateStrategy: 'memory'` — byte-identical runtime + behaviour, because `'disk'` already stored to memory. It is the spelling + that was false, not the behaviour. +- `stateStrategy: 'distributed'` → `stateStrategy: 'memory'` — same, or + `'none'` to disable state preservation outright. +- `distributedConfig: { … }` → *(removed)* — delete the key. It left with the + `'distributed'` value its own doc comment called it "required" for. +- `DistributedStateConfigSchema` / `DistributedStateConfig` / + `DistributedStateConfigParsed` → *(removed)* — the orphan value schema of + that one key. + +One-line fix: replace `'disk'` or `'distributed'` with `'memory'` and delete +any `distributedConfig` — you were already getting in-memory state. There is +no in-tree replacement for durable or distributed plugin state; persist it in +the host, which owns the process lifetime these strategies pretended to +outlive. Real disk or distributed persistence returns only via the ENFORCE +route of ADR-0049 — the implementation first, the declaration with it. + +The retirement kit: + +- **enum-value narrowing** (`['memory','disk','distributed','none']` → + `['memory','none']`): invisible to all four ratchets by construction (the + def still emits), so the prescription hangs on the enum's own `error` map + dispatched by `issue.input` — the `crypto.hash` / `managedBy: 'system'` + precedent. A value that was never legal still gets zod's own enum message, + so a typo is not told it "was removed". +- **whole-def deletion** (route 3 — `HotReloadConfig` is not an authorable + surface: no metadata-type binding, stack collection or manifest embed ever + carried it, and nothing in the tree parses `HotReloadConfigSchema` outside + its own unit test, so there is no authored document to rewrite and nobody + who could receive a parse-time tombstone): `kernel/DistributedStateConfig` + in `RETIRED_DEFS_BY_MAJOR[18]` plus the D3 semantic entry. Ratchets moved as + a def removal must — `api-surface` −3, `authorable-surface` −8, + `json-schema.manifest` −1. +- **runtime doors** in `@objectstack/core`, because route 3 leaves no + parse-time prescription: `HotReloadManager.registerPlugin` now refuses an + unhonoured `stateStrategy` and a leftover `distributedConfig` with an + ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`) carrying the + prescription. Refused BEFORE the `enabled` check, so a disabled config + cannot smuggle the false declaration through. TypeScript hosts never reach + it — `HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, a + compile error at the call site. +- **pin move, declared**: `DistributedStateConfigSchema` was NAMED in the + #11825 survivor list, so this reverses one line of that ruling on new + evidence — #11825 measured the container's six groups, never this key's own + readers. The pin in `kernel/plugin-lifecycle-advanced-retirement.test.ts` + moves in the same commit with the reasoning recorded beside it, and asserts + the surrounding keep is intact. +- zero in-tree consumers passed `'disk'` or `'distributed'` (measured at + cdbd9204b6 with a firing positive control; every live caller passes + `'memory'` or `'none'`), so no in-repo source changes ride along. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index fd47cc44d2..1e5ab3d435 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 — 1586 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1585 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/. */} @@ -26,14 +26,14 @@ counts are sums of the rows they head. Regenerate with | [Data Protocol](/docs/references/data) | 29 | 166 | 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 | 27 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | -| [Kernel Protocol](/docs/references/kernel) | 31 | 173 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | +| [Kernel Protocol](/docs/references/kernel) | 31 | 172 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | | [Security Protocol](/docs/references/security) | 5 | 27 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 8 | 32 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 288 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 152 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **199** | **1586** | 14 protocol modules | +| **Total** | **199** | **1585** | 14 protocol modules | --- @@ -215,7 +215,7 @@ The single connector protocol (ADR-0097) — catalog descriptors and provider-bo ## Kernel Protocol -**Source:** `packages/spec/src/kernel/` · **Import:** `@objectstack/spec/kernel` · **31 pages, 173 schemas** +**Source:** `packages/spec/src/kernel/` · **Import:** `@objectstack/spec/kernel` · **31 pages, 172 schemas** Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. @@ -242,7 +242,7 @@ Plugin lifecycle and manifests, capabilities and security, metadata loading, ser | [`package-upgrade.zod.ts`](/docs/references/kernel/package-upgrade) | `MetadataChangeType`, `MetadataDiffItem`, `RollbackPackageRequest`, `RollbackPackageResponse`, `UpgradeImpactLevel`, `UpgradePackageRequest`, `UpgradePackageResponse`, `UpgradePhase`, `UpgradePlan`, `UpgradeSnapshot` | | [`plugin.zod.ts`](/docs/references/kernel/plugin) | `Plugin` | | [`plugin-capability.zod.ts`](/docs/references/kernel/plugin-capability) | `CapabilityConformanceLevel`, `ExtensionPoint`, `PluginCapability`, `PluginCapabilityManifest`, `PluginDependency`, `PluginInterface`, `ProtocolFeature`, `ProtocolReference`, `ProtocolVersion` | -| [`plugin-lifecycle-advanced.zod.ts`](/docs/references/kernel/plugin-lifecycle-advanced) | `DistributedStateConfig`, `HotReloadConfig`, `PluginHealthCheck`, `PluginHealthReport`, `PluginHealthStatus`, `PluginStateSnapshot` | +| [`plugin-lifecycle-advanced.zod.ts`](/docs/references/kernel/plugin-lifecycle-advanced) | `HotReloadConfig`, `PluginHealthCheck`, `PluginHealthReport`, `PluginHealthStatus`, `PluginStateSnapshot` | | [`plugin-loading.zod.ts`](/docs/references/kernel/plugin-loading) | `PluginLoadingEvent`, `PluginLoadingState` | | [`plugin-registry.zod.ts`](/docs/references/kernel/plugin-registry) | `PluginInstallConfig`, `PluginQualityMetrics`, `PluginRegistryEntry`, `PluginSearchFilters`, `PluginStatistics`, `PluginVendor` | | [`plugin-security.zod.ts`](/docs/references/kernel/plugin-security) | `DependencyGraph`, `DependencyGraphNode`, `PackageDependencyConflict`, `PackageDependencyResolutionResult`, `PluginProvenance`, `PluginTrustScore`, `ResolvedPackageDependency`, `SBOM`, `SBOMEntry`, `SecurityPolicy`, `SecurityScanResult`, `SecurityVulnerability`, `VulnerabilitySeverity` | diff --git a/content/docs/references/kernel/plugin-lifecycle-advanced.mdx b/content/docs/references/kernel/plugin-lifecycle-advanced.mdx index 10dd5d156a..3f2db79127 100644 --- a/content/docs/references/kernel/plugin-lifecycle-advanced.mdx +++ b/content/docs/references/kernel/plugin-lifecycle-advanced.mdx @@ -25,30 +25,13 @@ the #11825 retirement record below. ## TypeScript Usage ```typescript -import { DistributedStateConfigSchema, HotReloadConfigSchema, PluginHealthCheckSchema, PluginHealthReportSchema, PluginHealthStatusSchema, PluginStateSnapshotSchema } from '@objectstack/spec/kernel'; -import type { DistributedStateConfig, HotReloadConfig, PluginHealthCheck, PluginHealthReport, PluginHealthStatus, PluginStateSnapshot } from '@objectstack/spec/kernel'; +import { HotReloadConfigSchema, PluginHealthCheckSchema, PluginHealthReportSchema, PluginHealthStatusSchema, PluginStateSnapshotSchema } from '@objectstack/spec/kernel'; +import type { HotReloadConfig, PluginHealthCheck, PluginHealthReport, PluginHealthStatus, PluginStateSnapshot } from '@objectstack/spec/kernel'; // Validate data -const result = DistributedStateConfigSchema.parse(data); +const result = HotReloadConfigSchema.parse(data); ``` ---- - -## DistributedStateConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `Enum<'redis' \| 'etcd' \| 'custom'>` | ✅ | Distributed state backend provider | -| **endpoints** | `string[]` | optional | Backend connection endpoints | -| **keyPrefix** | `string` | optional | Prefix for all keys (e.g., "plugin:my-plugin:") | -| **ttl** | `integer` | optional | State expiration time in seconds | -| **auth** | `{ username?: string; password?: string; token?: string; certificate?: string }` | optional | | -| **replication** | `{ enabled: boolean; minReplicas: integer }` | optional | | -| **customConfig** | `Record` | optional | Provider-specific configuration | - - --- ## HotReloadConfig @@ -61,24 +44,11 @@ const result = DistributedStateConfigSchema.parse(data); | **watchPatterns** | `string[]` | optional | Glob patterns to watch for changes | | **debounceDelay** | `integer` | optional (default: `1000`) | Wait time after change detection before reload | | **preserveState** | `boolean` | optional (default: `true`) | Keep plugin state across reloads | -| **stateStrategy** | `Enum<'memory' \| 'disk' \| 'distributed' \| 'none'>` | optional (default: `"memory"`) | How to preserve state during reload | -| **distributedConfig** | `{ provider: Enum<'redis' \| 'etcd' \| 'custom'>; endpoints?: string[]; keyPrefix?: string; ttl?: integer; … }` | optional | Configuration for distributed state management | +| **stateStrategy** | `Enum<'memory' \| 'none'>` | optional (default: `"memory"`) | How to preserve state during reload | | **shutdownTimeout** | `integer` | optional (default: `30000`) | Maximum time to wait for graceful shutdown | | **beforeReload** | `string[]` | optional | Hook names to call before reload | | **afterReload** | `string[]` | optional | Hook names to call after reload | -### Nested Shape: `HotReloadConfig.distributedConfig` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `Enum<'redis' \| 'etcd' \| 'custom'>` | ✅ | Distributed state backend provider | -| **endpoints** | `string[]` | optional | Backend connection endpoints | -| **keyPrefix** | `string` | optional | Prefix for all keys (e.g., "plugin:my-plugin:") | -| **ttl** | `integer` | optional | State expiration time in seconds | -| **auth** | `{ username?: string; password?: string; token?: string; certificate?: string }` | optional | | -| **replication** | `{ enabled: boolean; minReplicas: integer }` | optional | | -| **customConfig** | `Record` | optional | Provider-specific configuration | - --- 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 a601efbbf2..ce19699202 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -261,7 +261,7 @@ directory rather than per file. | `cloud/` | 83 | | `identity/` | 32 | | `integration/` | 10 | -| `kernel/` | 276 | +| `kernel/` | 273 | | `qa/` | 6 | | `shared/` | 20 | | `system/` | 361 | diff --git a/packages/core/src/hot-reload.test.ts b/packages/core/src/hot-reload.test.ts index c4ebc61b9a..4e53398c7f 100644 --- a/packages/core/src/hot-reload.test.ts +++ b/packages/core/src/hot-reload.test.ts @@ -208,3 +208,123 @@ describe('HotReloadManager', () => { }); }); }); + + +// ── [#12340] `stateStrategy` refuses what it cannot honour ────────────────── +// +// Before this card, `registerPlugin` accepted 'disk' and 'distributed' and +// `saveState` wrote both to the same in-memory Map as 'memory', reporting the +// substitution at DEBUG level only. A host that asked for durable or +// cluster-replicated state got process-local memory and no error — state that +// does not survive the restart it was configured to survive. +// +// Every assertion below is about observable behaviour: the refusal envelope, +// the prescription's load-bearing facts, and the fact that the honoured +// strategies still work. None of them asserts the source. +describe('[#12340] stateStrategy refusal', () => { + const configWith = (strategy: string): HotReloadConfigParsed => + ({ + enabled: true, + debounceDelay: 0, + preserveState: true, + stateStrategy: strategy, + shutdownTimeout: 1000, + }) as unknown as HotReloadConfigParsed; + + let mgr: HotReloadManager; + beforeEach(() => { + mgr = new HotReloadManager(createRecordingLogger([])); + }); + + for (const retired of ['disk', 'distributed']) { + it(`refuses '${retired}' at registration, with an ADR-0112 envelope`, () => { + let caught: (Error & { code?: string; status?: number }) | undefined; + try { + mgr.registerPlugin('p', configWith(retired)); + } catch (e) { + caught = e as Error & { code?: string; status?: number }; + } + + // The envelope, not merely "it threw" — a bare toThrow() would stay + // green against any unrelated failure on this path. + expect(caught, `'${retired}' must be refused`).toBeDefined(); + expect(caught?.code).toBe('VALIDATION_ERROR'); + expect(caught?.status).toBe(400); + + // The prescription's load-bearing facts. Pinned by CONTENT, never by + // byte-equality with the spec-side string: the two answer different + // doors (parse vs registration) and are deliberately not shared. + const m = caught?.message ?? ''; + expect(m).toContain(retired); + expect(m).toContain('#12340'); + expect(m).toContain('ADR-0049'); + expect(m).toContain('were removed'); + expect(m).toContain("Use 'memory'"); + expect(m).toContain('p'); // locates the offending plugin + }); + } + + it('refuses an unknown strategy WITHOUT claiming it was retired', () => { + // Anti-vacuity: a typo must not be told it "was removed" — that misinforms + // the author of `dsik`, who never had a working config to migrate from. + let caught: (Error & { code?: string }) | undefined; + try { + mgr.registerPlugin('p', configWith('dsik')); + } catch (e) { + caught = e as Error & { code?: string }; + } + expect(caught).toBeDefined(); + expect(caught?.code).toBe('VALIDATION_ERROR'); + expect(caught?.message).not.toContain('were removed'); + expect(caught?.message).toContain('never been implemented'); + }); + + it("refuses a leftover 'distributedConfig' instead of silently ignoring it", () => { + // The schema is not .strict(), so zod would STRIP this key on any parse + // path — a clean parse and a setting that never takes effect. #12340 took + // route 3 (no tombstone: nothing parses this schema), so THIS is the door + // that keeps the removal honest for the audience that exists. + const cfg = { + ...configWith('memory'), + distributedConfig: { provider: 'redis', endpoints: ['redis://localhost:6379'] }, + } as unknown as HotReloadConfigParsed; + + let caught: (Error & { code?: string; status?: number }) | undefined; + try { + mgr.registerPlugin('p', cfg); + } catch (e) { + caught = e as Error & { code?: string; status?: number }; + } + expect(caught).toBeDefined(); + expect(caught?.code).toBe('VALIDATION_ERROR'); + expect(caught?.status).toBe(400); + expect(caught?.message).toContain('distributedConfig'); + expect(caught?.message).toContain('#12340'); + expect(caught?.message).toContain('nothing ever read it'); + }); + + it('refuses even when hot reload is disabled', () => { + // The door must not depend on `enabled`: a false declaration is false + // whether or not the feature is switched on. + const cfg = { ...configWith('disk'), enabled: false } as HotReloadConfigParsed; + expect(() => mgr.registerPlugin('p', cfg)).toThrow(/#12340/); + }); + + for (const live of ['memory', 'none'] as const) { + it(`still registers and reloads with '${live}'`, async () => { + const cfg = configWith(live); + expect(() => mgr.registerPlugin('p', cfg)).not.toThrow(); + + const plugin = { + name: 'p', version: '1.0.0', init: () => {}, destroy: async () => {}, + } as unknown as Plugin; + let restored: Record | undefined; + const ok = await mgr.reloadPlugin( + 'p', plugin, '1.0.0', () => ({ hello: 'world' }), (st) => { restored = st; } + ); + expect(ok).toBe(true); + // 'memory' preserves state across the reload; 'none' deliberately does not. + expect(restored).toEqual(live === 'memory' ? { hello: 'world' } : undefined); + }); + } +}); diff --git a/packages/core/src/hot-reload.ts b/packages/core/src/hot-reload.ts index b1eca6f04f..81bd5015a9 100644 --- a/packages/core/src/hot-reload.ts +++ b/packages/core/src/hot-reload.ts @@ -22,6 +22,101 @@ const generateUUID = () => { }); }; +/** + * The `stateStrategy` values `PluginStateManager` actually implements. + * + * This is the ENFORCED set — it exists so the runtime door and the switch in + * `saveState` cannot drift apart silently. `@objectstack/spec`'s + * `HotReloadConfigSchema` declares exactly these two (#12340). + */ +const HONOURED_STATE_STRATEGIES = ['memory', 'none'] as const; + +/** + * Prescription for the two strategies retired in 18 (#12340). + * + * Deliberately PARALLEL to `HOT_RELOAD_STATE_STRATEGY_RETIRED` in + * `packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts` rather than + * imported from it: every `@objectstack/core` import of + * `@objectstack/spec/kernel` is type-only, and a value import would be the + * first — linking that module's zod closure into every consumer of this + * package for one string. The two prescriptions answer different doors (parse + * vs registration); the facts they must both carry are pinned in + * `hot-reload.test.ts`, by content and never by byte-equality. + */ +const RETIRED_STATE_STRATEGY_GUIDANCE = + "'disk' and 'distributed' were removed from HotReloadConfig.stateStrategy in " + + '@objectstack/spec 18 (#12340, ADR-0049 enforce-or-remove) — neither was ever ' + + "implemented. Both wrote to the same in-memory Map as 'memory' and reported it " + + 'only at debug level, so a host that asked for durable or cluster-replicated ' + + 'state got process-local memory and no error. ' + + "Use 'memory' for in-process state preservation across a reload, or 'none' to " + + 'disable it. There is no in-tree replacement for durable or distributed plugin ' + + 'state — persist it in the host, which owns the process lifetime these ' + + 'strategies pretended to outlive.'; + +/** + * An ADR-0112-enveloped refusal (`code` + `status` on the error), so a caller — + * and a rejection-class test — can assert the refusal rather than merely "it + * threw". `VALIDATION_ERROR` is the standard catalog's generic + * argument-validation code, following `metadata-service-contract.ts`. + */ +function stateStrategyRefusal(message: string): Error & { code: string; status: number } { + const err = new Error(message) as Error & { code: string; status: number }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + return err; +} + +/** + * Refuse a `stateStrategy` this library does not implement, at the moment the + * host hands the config over. + * + * TypeScript hosts cannot reach this: `HotReloadConfigParsed['stateStrategy']` + * is `'memory' | 'none'`, so `'disk'` is a compile error. This is the door for + * the callers types do not reach — JavaScript hosts, and config that arrived + * as JSON — which is exactly where a silent fallback used to live. + */ +function assertHonouredStateStrategy(pluginName: string, strategy: unknown): void { + if ((HONOURED_STATE_STRATEGIES as readonly unknown[]).includes(strategy)) { + return; + } + const shown = typeof strategy === 'string' ? `'${strategy}'` : String(strategy); + const retired = strategy === 'disk' || strategy === 'distributed'; + throw stateStrategyRefusal( + `[HotReload] Plugin '${pluginName}': unsupported stateStrategy ${shown}. ` + + `Honoured values are ${HONOURED_STATE_STRATEGIES.map((v) => `'${v}'`).join(' and ')}. ` + + (retired + ? RETIRED_STATE_STRATEGY_GUIDANCE + : 'This value has never been implemented by PluginStateManager.') + ); +} + +/** + * Refuse a `distributedConfig` left over from before #12340. + * + * `HotReloadConfigSchema` is not `.strict()`, so zod would silently STRIP this + * key on the parse paths that exist — a clean parse and a setting that never + * takes effect, which is the exact failure the authorable-surface gate names. + * The key was removed rather than tombstoned (route 3: nothing in the tree + * parses this schema, so a parse-time prescription reaches nobody), and this + * is the door that makes that route honest for the audience that DOES exist — + * a host handing the object straight to the class. + */ +function assertNoRetiredDistributedConfig(pluginName: string, config: object): void { + if (!Object.prototype.hasOwnProperty.call(config, 'distributedConfig')) { + return; + } + throw stateStrategyRefusal( + `[HotReload] Plugin '${pluginName}': 'distributedConfig' was removed from ` + + 'HotReloadConfig in @objectstack/spec 18 (#12340, ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it. A provider, endpoints, a key ' + + 'prefix, a TTL and a replication factor could all be declared and no ' + + "connection was ever opened. It left with the stateStrategy: 'distributed' " + + 'value it was documented as being required for. Delete the key; there is no ' + + 'in-tree replacement for distributed plugin state — persist it in the host.' + ); +} + /** * Plugin State Manager * @@ -64,23 +159,6 @@ class PluginStateManager { this.logger.debug('State saved to memory', { pluginId, snapshotId }); break; - case 'disk': - // For disk storage, we would write to file system - // For now, store in memory as fallback - this.memoryStore.set(snapshotId, snapshot); - this.logger.debug('State saved to disk (memory fallback)', { pluginId, snapshotId }); - break; - - case 'distributed': - // For distributed storage, would use Redis/etcd - // For now, store in memory as fallback - this.memoryStore.set(snapshotId, snapshot); - this.logger.debug('State saved to distributed store (memory fallback)', { - pluginId, - snapshotId - }); - break; - case 'none': this.logger.debug('State persistence disabled', { pluginId }); break; @@ -176,6 +254,14 @@ export class HotReloadManager { * Register a plugin for hot reload */ registerPlugin(pluginName: string, config: HotReloadConfigParsed): void { + // Refused BEFORE the `enabled` check on purpose: a config naming a + // strategy this library cannot honour is malformed whether or not hot + // reload is switched on, and a door that opens only for `enabled: true` + // would let the false declaration through on exactly the configs nobody + // is watching. + assertHonouredStateStrategy(pluginName, config.stateStrategy); + assertNoRetiredDistributedConfig(pluginName, config); + if (!config.enabled) { this.logger.debug('Hot reload disabled for plugin', { plugin: pluginName }); return; diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index f30c26f54d..ab472c6de6 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -64,9 +64,6 @@ "DisablePackageResponse (type)", "DisablePackageResponseParsed (type)", "DisablePackageResponseSchema (const)", - "DistributedStateConfig (type)", - "DistributedStateConfigParsed (type)", - "DistributedStateConfigSchema (const)", "EVENT_PRIORITY_VALUES (const)", "EnablePackageRequest (type)", "EnablePackageRequestSchema (const)", diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index ca1ad66b0c..85978d1ed7 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -82,13 +82,6 @@ "kernel/DisablePackageRequest:id", "kernel/DisablePackageResponse:message", "kernel/DisablePackageResponse:package", - "kernel/DistributedStateConfig:auth", - "kernel/DistributedStateConfig:customConfig", - "kernel/DistributedStateConfig:endpoints", - "kernel/DistributedStateConfig:keyPrefix", - "kernel/DistributedStateConfig:provider", - "kernel/DistributedStateConfig:replication", - "kernel/DistributedStateConfig:ttl", "kernel/EnablePackageRequest:id", "kernel/EnablePackageResponse:message", "kernel/EnablePackageResponse:package", @@ -231,7 +224,6 @@ "kernel/HotReloadConfig:afterReload", "kernel/HotReloadConfig:beforeReload", "kernel/HotReloadConfig:debounceDelay", - "kernel/HotReloadConfig:distributedConfig", "kernel/HotReloadConfig:enabled", "kernel/HotReloadConfig:preserveState", "kernel/HotReloadConfig:shutdownTimeout", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index 63b03f322d..0fa8a165d2 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -64,9 +64,6 @@ "DisablePackageResponse": "src/kernel/package-registry.zod.ts#DisablePackageResponse (type)", "DisablePackageResponseParsed": "src/kernel/package-registry.zod.ts#DisablePackageResponseParsed (type)", "DisablePackageResponseSchema": "src/kernel/package-registry.zod.ts#DisablePackageResponseSchema (const)", - "DistributedStateConfig": "src/kernel/plugin-lifecycle-advanced.zod.ts#DistributedStateConfig (type)", - "DistributedStateConfigParsed": "src/kernel/plugin-lifecycle-advanced.zod.ts#DistributedStateConfigParsed (type)", - "DistributedStateConfigSchema": "src/kernel/plugin-lifecycle-advanced.zod.ts#DistributedStateConfigSchema (const)", "EVENT_PRIORITY_VALUES": "src/kernel/events/core.zod.ts#EVENT_PRIORITY_VALUES (const)", "EnablePackageRequest": "src/kernel/package-registry.zod.ts#EnablePackageRequest (type)", "EnablePackageRequestSchema": "src/kernel/package-registry.zod.ts#EnablePackageRequestSchema (const)", diff --git a/packages/spec/json-schema.manifest/kernel.json b/packages/spec/json-schema.manifest/kernel.json index 90ef2d9cf3..7377645815 100644 --- a/packages/spec/json-schema.manifest/kernel.json +++ b/packages/spec/json-schema.manifest/kernel.json @@ -24,7 +24,6 @@ "kernel/DeprecationNotice", "kernel/DisablePackageRequest", "kernel/DisablePackageResponse", - "kernel/DistributedStateConfig", "kernel/EnablePackageRequest", "kernel/EnablePackageResponse", "kernel/Event", diff --git a/packages/spec/src/kernel/plugin-lifecycle-advanced-retirement.test.ts b/packages/spec/src/kernel/plugin-lifecycle-advanced-retirement.test.ts index 73d485ecf9..49271e27cc 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-advanced-retirement.test.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-advanced-retirement.test.ts @@ -66,13 +66,49 @@ describe('[#11825] kernel/ AdvancedPluginLifecycleConfig retirement', () => { 'PluginHealthCheckSchema', 'PluginHealthReportSchema', 'HotReloadConfigSchema', - 'DistributedStateConfigSchema', + // 'DistributedStateConfigSchema' — MOVED OUT by #12340, see below. 'PluginStateSnapshotSchema', 'PluginHealthCheckParsed', 'HotReloadConfigParsed', 'PluginStateSnapshot', ] as const; + /** + * [#12340] The one name this ruling's survivor list NAMED and a later card + * removed anyway. + * + * Not a quiet edit to make a red pin green — the reversal is the finding. + * #11825 measured the CONTAINER's six groups and kept `HotReloadConfig` + * "with its embedded `DistributedStateConfig`" as a library parameter type. + * It never measured THIS key's own readers. #12340 did, at cdbd9204b6 with + * a firing positive control, and found zero: an author could name a Redis + * endpoint, a TTL and a replication factor, and nothing ever opened a + * connection. Its only referencing key (`HotReloadConfig.distributedConfig`) + * left with the `stateStrategy: 'distributed'` value it was documented as + * being "required" for, so the schema had nothing left to be the vocabulary + * OF. + * + * The keep itself is intact and still pinned above: `HotReloadConfigSchema` + * survives, `HotReloadManager` survives. What #12340 removed is the part of + * the kept vocabulary that was itself declared-but-unenforced — the same + * ADR-0049 test that retired the container, applied one level in. + */ + const RETIRED_BY_12340 = ['DistributedStateConfigSchema', 'DistributedStateConfig', + 'DistributedStateConfigParsed'] as const; + + it('[#12340] the distributed-state vocabulary has zero holders too', () => { + // Anti-vacuity: the same baseline the sibling assertion relies on. + expect(exportNamesOf('./kernel').length).toBeGreaterThan(50); + for (const name of RETIRED_BY_12340) { + expect(holdersOf(name), `${name} must have zero holders after #12340`).toEqual([]); + } + // The keep it was carved out of is UNTOUCHED — this is the assertion that + // makes the removal a narrowing rather than the "too-wide sweep" the + // survivor list was written to stop. + expect(exportNamesOf('./kernel')).toContain('HotReloadConfigSchema'); + expect(exportNamesOf('./kernel')).toContain('PluginStateSnapshotSchema'); + }); + 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']) { @@ -108,6 +144,8 @@ describe('[#11825] kernel/ AdvancedPluginLifecycleConfig retirement', () => { // library vocabularies (the #4914 §2 keep, restated by this ruling). expect(kernel).toHaveProperty('PluginHealthCheckSchema'); expect(kernel).toHaveProperty('HotReloadConfigSchema'); + // [#12340] gone from the barrel with its def; the keep around it stands. + expect(kernel).not.toHaveProperty('DistributedStateConfigSchema'); expect(kernel).toHaveProperty('PluginStateSnapshotSchema'); expect(kernel).toHaveProperty('ManifestSchema'); }); diff --git a/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts b/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts index d7cbb331d2..c8bf13b821 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts @@ -3,7 +3,6 @@ import { PluginHealthStatusSchema, PluginHealthCheckSchema, PluginHealthReportSchema, - DistributedStateConfigSchema, HotReloadConfigSchema, PluginStateSnapshotSchema, } from './plugin-lifecycle-advanced.zod'; @@ -125,7 +124,7 @@ describe('Plugin Lifecycle Advanced Schemas', () => { watchPatterns: ['src/**/*.ts', 'config/**/*.json'], debounceDelay: 2000, preserveState: false, - stateStrategy: 'disk' as const, + stateStrategy: 'memory' as const, shutdownTimeout: 60000, beforeReload: ['beforeReloadHook'], afterReload: ['afterReloadHook'], @@ -134,69 +133,57 @@ describe('Plugin Lifecycle Advanced Schemas', () => { expect(result).toEqual(config); }); - it('should validate distributed state strategy', () => { - const config = { - enabled: true, - stateStrategy: 'distributed' as const, - distributedConfig: { - provider: 'redis' as const, - endpoints: ['redis://localhost:6379'], - keyPrefix: 'plugin:my-plugin:', - ttl: 3600, - }, - }; - const result = HotReloadConfigSchema.parse(config); - expect(result.stateStrategy).toBe('distributed'); - expect(result.distributedConfig?.provider).toBe('redis'); - expect(result.distributedConfig?.keyPrefix).toBe('plugin:my-plugin:'); + // ── [#12340] The two retired strategies are REFUSED, with the prescription ── + // + // This block replaces the fixture that pinned the deleted 'distributed' + // arm. That fixture passed precisely BECAUSE the arm existed and did + // nothing: it asserted the value survived the parse, which was true right + // up to the moment it stopped meaning anything at runtime. + for (const retired of ['disk', 'distributed'] as const) { + it(`refuses stateStrategy '${retired}' with the retirement prescription`, () => { + const result = HotReloadConfigSchema.safeParse({ enabled: true, stateStrategy: retired }); + expect(result.success, `'${retired}' must no longer parse`).toBe(false); + + // The message IS the contract here — it is the whole migration + // document for whoever hits it. Assert the load-bearing clauses, not + // the byte string. + const message = result.success ? '' : result.error.issues[0]?.message ?? ''; + expect(message).toContain('were removed'); + expect(message).toContain('#12340'); + expect(message).toContain('ADR-0049'); + expect(message).toMatch(/memory fallback|in-memory Map/); + expect(message).toContain("Use 'memory'"); + }); + } + + it('keeps zod\'s own enum message for a value that was never legal', () => { + // Anti-vacuity for the error map: a typo must NOT be told it "was + // removed" — that would misinform the author of `dsik` (the + // `crypto.hash` precedent's exact reasoning). + const result = HotReloadConfigSchema.safeParse({ stateStrategy: 'dsik' }); + expect(result.success).toBe(false); + const message = result.success ? '' : result.error.issues[0]?.message ?? ''; + expect(message).not.toContain('were removed'); + expect(message).not.toContain('#12340'); }); - }); - describe('DistributedStateConfigSchema', () => { - it('should validate Redis configuration', () => { - const config = { - provider: 'redis' as const, - endpoints: ['redis://localhost:6379', 'redis://localhost:6380'], - keyPrefix: 'objectstack:', - ttl: 7200, - auth: { - username: 'admin', - password: 'secret', - }, - replication: { - enabled: true, - minReplicas: 2, - }, - }; - const result = DistributedStateConfigSchema.parse(config); - expect(result.provider).toBe('redis'); - expect(result.endpoints).toHaveLength(2); - expect(result.ttl).toBe(7200); + it('still accepts the two strategies the runtime implements', () => { + for (const live of ['memory', 'none'] as const) { + const result = HotReloadConfigSchema.safeParse({ enabled: true, stateStrategy: live }); + expect(result.success, `'${live}' must still parse`).toBe(true); + } }); - it('should validate Etcd configuration', () => { - const config = { - provider: 'etcd' as const, - endpoints: ['http://localhost:2379'], - auth: { - certificate: '/path/to/cert.pem', - }, - }; - const result = DistributedStateConfigSchema.parse(config); - expect(result.provider).toBe('etcd'); - }); - - it('should validate custom provider configuration', () => { - const config = { - provider: 'custom' as const, - customConfig: { - type: 'consul', - address: 'consul.example.com:8500', - }, - }; - const result = DistributedStateConfigSchema.parse(config); - expect(result.provider).toBe('custom'); - expect(result.customConfig).toBeDefined(); + it('no longer accepts distributedConfig as a declarable key', () => { + // Non-strict object: the key is silently stripped rather than refused. + // Pinning the STRIP is the honest assertion — it is what actually + // happens, and it is why the prescription had to hang on the enum + // (which IS refused) rather than on this key. + const result = HotReloadConfigSchema.parse({ + enabled: true, + distributedConfig: { provider: 'redis', endpoints: ['redis://localhost:6379'] }, + } as Record); + expect(result).not.toHaveProperty('distributedConfig'); }); }); diff --git a/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts b/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts index 160c0cf030..ae1e3dfaca 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts @@ -137,59 +137,35 @@ export const PluginHealthReportSchema = lazySchema(() => z.object({ })).optional(), })); + /** - * Distributed State Configuration - * Configuration for distributed state management in cluster environments + * Prescription for the two `stateStrategy` values retired in 18 (#12340). + * + * Deliberately carries NO `os migrate meta --from 17` sentence. That command + * replays the conversion chain over authored METADATA SOURCES, and + * `HotReloadConfig` is not an authorable surface — it is a library parameter a + * host passes to `HotReloadManager` in TypeScript (the #4914 / #11825 keep). + * No authored document has ever been able to carry `stateStrategy`, so naming + * the command here would promise an affordance that cannot apply — the same + * false-promise defect ADR-0049 exists to prevent. The migrate-sentence pin + * judges only prescriptions that DO name the command, so its absence is in + * scope by construction, not by exemption. */ -export const DistributedStateConfigSchema = lazySchema(() => z.object({ - /** - * Distributed cache provider - */ - provider: z.enum(['redis', 'etcd', 'custom']) - .describe('Distributed state backend provider'), - - /** - * Connection URL or endpoints - */ - endpoints: z.array(z.string()).optional() - .describe('Backend connection endpoints'), - - /** - * Key prefix for namespacing - */ - keyPrefix: z.string().optional() - .describe('Prefix for all keys (e.g., "plugin:my-plugin:")'), - - /** - * Time to live in seconds - */ - ttl: z.number().int().min(0).optional() - .describe('State expiration time in seconds'), - - /** - * Authentication configuration - */ - auth: z.object({ - username: z.string().optional(), - password: z.string().optional(), - token: z.string().optional(), - certificate: z.string().optional(), - }).optional(), - - /** - * Replication settings - */ - replication: z.object({ - enabled: z.boolean().default(true), - minReplicas: z.number().int().min(1).default(1), - }).optional(), - - /** - * Custom provider configuration - */ - customConfig: z.record(z.string(), z.unknown()).optional() - .describe('Provider-specific configuration'), -})); +const HOT_RELOAD_STATE_STRATEGY_RETIRED = + "`HotReloadConfig.stateStrategy: 'disk'` and `HotReloadConfig.stateStrategy: " + + "'distributed'` were removed in @objectstack/spec 18 (#12340, ADR-0049 " + + 'enforce-or-remove) — neither was ever implemented. Both switch arms in ' + + "`PluginStateManager.saveState` wrote to the SAME in-memory Map as 'memory' " + + "(the in-source comments said 'memory fallback'), and the only trace was a " + + 'debug-level log line, so a host that asked for disk or cluster-replicated ' + + 'state got process-local memory and no error — state that does not survive ' + + "the restart it was configured to survive. Use 'memory' if in-process " + + "state preservation across a reload is what you want, or 'none' to disable " + + 'it; there is no in-tree replacement for durable or distributed plugin ' + + 'state. Persist it in the host instead — the host owns the process ' + + 'lifetime these strategies pretended to outlive. Real disk or distributed ' + + 'persistence returns only via the ENFORCE route of ADR-0049: the ' + + 'implementation first, the declaration with it.'; /** * Hot Reload Configuration @@ -220,17 +196,24 @@ export const HotReloadConfigSchema = lazySchema(() => z.object({ .describe('Keep plugin state across reloads'), /** - * State serialization strategy - */ - stateStrategy: z.enum(['memory', 'disk', 'distributed', 'none']).default('memory') + * State serialization strategy. + * + * `'disk'` and `'distributed'` were REMOVED in 18 (#12340) — see + * `HOT_RELOAD_STATE_STRATEGY_RETIRED` above. Only the two values + * `PluginStateManager` actually implements are declarable. + */ + stateStrategy: z.enum(['memory', 'none'], { + // Only the two values that USED to be legal get the retirement + // prescription; every other input keeps zod's own enum message, which + // already lists the legal tokens. (`HookBodyCapability`'s `crypto.hash` + // is the model, itself following `object.managedBy: 'system'`.) + error: (issue) => + issue.input === 'disk' || issue.input === 'distributed' + ? HOT_RELOAD_STATE_STRATEGY_RETIRED + : undefined, + }).default('memory') .describe('How to preserve state during reload'), - /** - * Distributed state configuration (required when stateStrategy is "distributed") - */ - distributedConfig: DistributedStateConfigSchema.optional() - .describe('Configuration for distributed state management'), - /** * Graceful shutdown timeout */ @@ -288,12 +271,41 @@ export const HotReloadConfigSchema = lazySchema(() => z.object({ // // What SURVIVES, deliberately: the input vocabularies of the host-driven // library classes the ruling keeps — `PluginHealthStatus` / `PluginHealthCheck` -// / `PluginHealthReport`, `HotReloadConfig` (with its embedded -// `DistributedStateConfig`) and `PluginStateSnapshot`. They are library +// / `PluginHealthReport`, `HotReloadConfig` and `PluginStateSnapshot`. They are library // parameter types, not an authorable config surface: a host constructs the // classes and passes these shapes in TypeScript, which is exactly what the // #4914 ruling kept `HotReloadConfigSchema` for. // +// ── [#12340] AMENDED 2026-08-26: the kept library keeps only what it honours ─ +// +// The keep above is INTACT — `HotReloadConfigSchema` and `HotReloadManager` +// stay. What left is the part of the kept vocabulary that was itself +// declared-but-unenforced, measured at cdbd9204b6 with a firing positive +// control (`stateStrategy` resolves to real readers in +// `core/src/hot-reload.ts`; `distributedConfig` resolves to nothing outside +// `packages/spec` in objectstack, and to nothing in objectui): +// +// - `stateStrategy` narrowed ['memory','disk','distributed','none'] -> +// ['memory','none']. The 'disk' and 'distributed' arms of +// `PluginStateManager.saveState` both wrote to the in-memory Map and said +// so only at debug level. An enum-VALUE narrowing is invisible to the four +// ratchets (the def still emits), so the prescription hangs on the enum's +// own `error` map — `HOT_RELOAD_STATE_STRATEGY_RETIRED`, dispatched by +// `issue.input`. +// - `DistributedStateConfigSchema` / `distributedConfig` REMOVED. Zero +// readers anywhere: an author could name a Redis endpoint and a TTL and +// nothing ever opened a connection. It was the orphan value schema of the +// one key that referenced it, and its documented trigger ("required when +// stateStrategy is \"distributed\"") names a value that no longer exists — +// so it could not honestly outlive the narrowing. A whole def leaving MUST +// move the ratchets; that movement is the route's own evidence. +// +// The 2026-08-25 keep listed `DistributedStateConfig` among the survivors, so +// this card REVERSES a named line of that ruling on new evidence (that ruling +// measured the CONTAINER's groups, never this key's own readers). The pin in +// `plugin-lifecycle-advanced-retirement.test.ts` moves with it, deliberately +// and in the same commit — never as a quiet edit to make a red pin green. +// // Route 3 (no tombstone, no conversion): with no carrier key and no authored // document there is nothing to tombstone and no seam for a D2 conversion — // `RETIRED_DEFS_BY_MAJOR[18]` (`kernel/AdvancedPluginLifecycleConfig`, @@ -345,9 +357,6 @@ export type PluginHealthCheck = z.input; /** Post-parse shape of {@link PluginHealthCheck} — defaults applied, transforms run (ADR-0122). */ export type PluginHealthCheckParsed = z.infer; export type PluginHealthReport = z.input; -export type DistributedStateConfig = z.input; -/** Post-parse shape of {@link DistributedStateConfig} — defaults applied, transforms run (ADR-0122). */ -export type DistributedStateConfigParsed = z.infer; export type HotReloadConfig = z.input; /** Post-parse shape of {@link HotReloadConfig} — defaults applied, transforms run (ADR-0122). */ export type HotReloadConfigParsed = z.infer; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.kernel__DistributedStateConfig.ts b/packages/spec/src/migrations/entries/retired-defs/18.kernel__DistributedStateConfig.ts new file mode 100644 index 0000000000..bc090e6a8e --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.kernel__DistributedStateConfig.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #12340 — kernel/plugin-lifecycle-advanced.zod.ts +// `DistributedStateConfigSchema`, retired whole (ADR-0049 enforce-or-remove). +// +// The orphan value schema of `HotReloadConfig.distributedConfig`, which had +// ZERO readers — measured at cdbd9204b6 with a firing positive control +// (`stateStrategy` resolves to real readers in `core/src/hot-reload.ts`, so +// the scan sees readers; `distributedConfig` resolved to nothing outside +// `packages/spec` itself in objectstack, and to nothing in objectui). An +// author could name a Redis or etcd endpoint, a key prefix, a TTL, auth +// credentials and a replication factor, and NOTHING ever opened a connection +// — the #3950 shape, sharpened by cluster-persistence vocabulary an AI author +// (ADR-0033) reads as proof the capability exists. +// +// It leaves with the enum value it existed for. `HotReloadConfig.stateStrategy` +// narrowed to ['memory','none'] in the same card, because the 'disk' and +// 'distributed' arms of `PluginStateManager.saveState` both wrote to the same +// in-memory Map as 'memory' and said so at DEBUG level only. With +// 'distributed' gone, a key whose own doc comment called it "required when +// stateStrategy is 'distributed'" names a value that no longer exists — it +// could not honestly outlive the narrowing. +// +// This is the #11825 keep, narrowed from inside, NOT reversed: that ruling +// kept `HotReloadConfigSchema` and `HotReloadManager` as a host-driven library +// and they both stand here. What it also listed among the survivors was +// `DistributedStateConfig` — a line this card reverses on new evidence, since +// #11825 measured the CONTAINER's six groups and never this key's own readers. +// The survival pin in `plugin-lifecycle-advanced-retirement.test.ts` moves in +// the same commit, deliberately and with the reasoning recorded there. +// +// Route 3: `HotReloadConfig` is not an authorable surface — no metadata-type +// binding, stack collection or manifest embed ever carried it, and nothing in +// the tree parses `HotReloadConfigSchema` outside its own unit test — so there +// is no authored document for a D2 conversion to rewrite and nobody who could +// receive a parse-time tombstone. This table plus the D3 semantic entry +// `hot-reload-inert-state-strategies-retired` ARE the declaration. +export const entry = 'kernel/DistributedStateConfig'; diff --git a/packages/spec/src/migrations/entries/semantic/18.hot-reload-inert-state-strategies-retired.ts b/packages/spec/src/migrations/entries/semantic/18.hot-reload-inert-state-strategies-retired.ts new file mode 100644 index 0000000000..2cc5792752 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.hot-reload-inert-state-strategies-retired.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'hot-reload-inert-state-strategies-retired', + surface: + "`HotReloadConfig.stateStrategy` values 'disk' and 'distributed', plus the " + + '`HotReloadConfig.distributedConfig` key and the `DistributedStateConfig` def ' + + 'it carried (3 exported names: `DistributedStateConfigSchema` / ' + + '`DistributedStateConfig` / `DistributedStateConfigParsed`)', + replacement: + "'memory' for in-process state preservation across a reload, or 'none' to " + + 'disable it — the two values `PluginStateManager` actually implements. There ' + + 'is no in-tree replacement for durable or distributed plugin state: persist ' + + 'it in the host, which owns the process lifetime these strategies pretended ' + + 'to outlive. Real disk or distributed persistence returns only via the ' + + 'ENFORCE route of ADR-0049 — the implementation first, the declaration with ' + + 'it.', + reason: + 'ADR-0049 enforce-or-remove, applied one level INSIDE the library the ' + + '2026-08-25 #11825 ruling kept. That ruling retired the authorable ' + + 'lifecycle-config container and deliberately kept `HotReloadConfigSchema` as ' + + 'a host-driven library parameter type; this card measured the kept ' + + "vocabulary's own remainder and found the same defect in it. Measured at " + + 'cdbd9204b6 with a firing positive control (`stateStrategy` resolves to real ' + + 'readers in `core/src/hot-reload.ts`, so the scan sees readers): the ' + + "'disk' and 'distributed' arms of `PluginStateManager.saveState` both wrote " + + "to the SAME in-memory Map as 'memory' — the in-source comments said " + + "'memory fallback' — and announced the substitution at DEBUG level only, so " + + 'a host that asked for durable or cluster-replicated state got process-local ' + + 'memory and no error: state that does not survive the restart it was ' + + 'configured to survive. `distributedConfig` had ZERO readers anywhere ' + + '(every reference inside `packages/spec` itself plus the generated reference ' + + 'page; nothing in objectui), so an author could name a Redis endpoint, a TTL ' + + 'and a replication factor and nothing ever opened a connection — the #3950 ' + + 'shape, sharpened by cluster-persistence vocabulary an AI author (ADR-0033) ' + + 'reads as proof the capability exists. The key left with the enum value its ' + + 'own doc comment named it "required" for, and `DistributedStateConfig` was ' + + 'its orphan value schema. Two routes in one card because the surface has two ' + + 'shapes: an enum-VALUE narrowing is invisible to the four ratchets (the def ' + + 'still emits), so its prescription hangs on the enum\'s own `error` map ' + + 'dispatched by `issue.input` (the `crypto.hash` / `managedBy: \'system\'` ' + + 'precedent); the whole-def removal MUST move them, and that movement is its ' + + 'own evidence. No D2 conversion and no tombstone: `HotReloadConfig` is not ' + + 'an authorable surface — no metadata-type binding, stack collection or ' + + 'manifest embed ever carried it, and nothing in the tree parses ' + + '`HotReloadConfigSchema` outside its own unit test — so there is no authored ' + + 'document to rewrite and no one who could receive a parse-time ' + + 'prescription. Route 3, the #4834 / #11825 shape: this entry IS the ' + + 'declaration.', + acceptanceCriteria: + "No host passes `stateStrategy: 'disk'` or `'distributed'` to " + + '`HotReloadManager.registerPlugin`. TypeScript hosts cannot: ' + + "`HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, so " + + 'either value is a compile error at the call site. JavaScript hosts, and ' + + 'config that arrived as JSON, get a loud registration-time refusal carrying ' + + 'the prescription — an ADR-0112 envelope (`code: VALIDATION_ERROR`, ' + + '`status: 400`) thrown BEFORE the `enabled` check, so a disabled config ' + + 'cannot smuggle the false declaration through. No import of ' + + '`DistributedStateConfigSchema`, `DistributedStateConfig` or ' + + '`DistributedStateConfigParsed` from `@objectstack/spec` or ' + + '`@objectstack/spec/kernel` survives — every one is TS2305 after upgrade, ' + + 'pinned by resolved symbol identity in ' + + '`kernel/plugin-lifecycle-advanced-retirement.test.ts`. ⚠️ Runtime state ' + + "behaviour is UNCHANGED for every config that worked: 'disk' and " + + "'distributed' already stored to memory, so a host that migrates either to " + + "'memory' keeps byte-identical behaviour — what changes is that the two " + + 'spellings which never described what happened are now refused instead of ' + + 'silently honoured. The #11825 keep itself stands: `HotReloadConfigSchema`, ' + + '`PluginStateSnapshotSchema` and the health vocabularies still export from ' + + '`./kernel`, and `HotReloadManager` / `PluginHealthMonitor` still export ' + + 'from `@objectstack/core` with their tests green.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 89ffbb95b0..10ed4aef82 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6111,6 +6111,76 @@ const step18: MigrationStep = { + 'door, 400 after), so re-check what the surface was supposed to show rather than ' + 'assuming the old result set was correct.', }, + { + id: 'hot-reload-inert-state-strategies-retired', + surface: + "`HotReloadConfig.stateStrategy` values 'disk' and 'distributed', plus the " + + '`HotReloadConfig.distributedConfig` key and the `DistributedStateConfig` def ' + + 'it carried (3 exported names: `DistributedStateConfigSchema` / ' + + '`DistributedStateConfig` / `DistributedStateConfigParsed`)', + replacement: + "'memory' for in-process state preservation across a reload, or 'none' to " + + 'disable it — the two values `PluginStateManager` actually implements. There ' + + 'is no in-tree replacement for durable or distributed plugin state: persist ' + + 'it in the host, which owns the process lifetime these strategies pretended ' + + 'to outlive. Real disk or distributed persistence returns only via the ' + + 'ENFORCE route of ADR-0049 — the implementation first, the declaration with ' + + 'it.', + reason: + 'ADR-0049 enforce-or-remove, applied one level INSIDE the library the ' + + '2026-08-25 #11825 ruling kept. That ruling retired the authorable ' + + 'lifecycle-config container and deliberately kept `HotReloadConfigSchema` as ' + + 'a host-driven library parameter type; this card measured the kept ' + + "vocabulary's own remainder and found the same defect in it. Measured at " + + 'cdbd9204b6 with a firing positive control (`stateStrategy` resolves to real ' + + 'readers in `core/src/hot-reload.ts`, so the scan sees readers): the ' + + "'disk' and 'distributed' arms of `PluginStateManager.saveState` both wrote " + + "to the SAME in-memory Map as 'memory' — the in-source comments said " + + "'memory fallback' — and announced the substitution at DEBUG level only, so " + + 'a host that asked for durable or cluster-replicated state got process-local ' + + 'memory and no error: state that does not survive the restart it was ' + + 'configured to survive. `distributedConfig` had ZERO readers anywhere ' + + '(every reference inside `packages/spec` itself plus the generated reference ' + + 'page; nothing in objectui), so an author could name a Redis endpoint, a TTL ' + + 'and a replication factor and nothing ever opened a connection — the #3950 ' + + 'shape, sharpened by cluster-persistence vocabulary an AI author (ADR-0033) ' + + 'reads as proof the capability exists. The key left with the enum value its ' + + 'own doc comment named it "required" for, and `DistributedStateConfig` was ' + + 'its orphan value schema. Two routes in one card because the surface has two ' + + 'shapes: an enum-VALUE narrowing is invisible to the four ratchets (the def ' + + 'still emits), so its prescription hangs on the enum\'s own `error` map ' + + 'dispatched by `issue.input` (the `crypto.hash` / `managedBy: \'system\'` ' + + 'precedent); the whole-def removal MUST move them, and that movement is its ' + + 'own evidence. No D2 conversion and no tombstone: `HotReloadConfig` is not ' + + 'an authorable surface — no metadata-type binding, stack collection or ' + + 'manifest embed ever carried it, and nothing in the tree parses ' + + '`HotReloadConfigSchema` outside its own unit test — so there is no authored ' + + 'document to rewrite and no one who could receive a parse-time ' + + 'prescription. Route 3, the #4834 / #11825 shape: this entry IS the ' + + 'declaration.', + acceptanceCriteria: + "No host passes `stateStrategy: 'disk'` or `'distributed'` to " + + '`HotReloadManager.registerPlugin`. TypeScript hosts cannot: ' + + "`HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, so " + + 'either value is a compile error at the call site. JavaScript hosts, and ' + + 'config that arrived as JSON, get a loud registration-time refusal carrying ' + + 'the prescription — an ADR-0112 envelope (`code: VALIDATION_ERROR`, ' + + '`status: 400`) thrown BEFORE the `enabled` check, so a disabled config ' + + 'cannot smuggle the false declaration through. No import of ' + + '`DistributedStateConfigSchema`, `DistributedStateConfig` or ' + + '`DistributedStateConfigParsed` from `@objectstack/spec` or ' + + '`@objectstack/spec/kernel` survives — every one is TS2305 after upgrade, ' + + 'pinned by resolved symbol identity in ' + + '`kernel/plugin-lifecycle-advanced-retirement.test.ts`. ⚠️ Runtime state ' + + "behaviour is UNCHANGED for every config that worked: 'disk' and " + + "'distributed' already stored to memory, so a host that migrates either to " + + "'memory' keeps byte-identical behaviour — what changes is that the two " + + 'spellings which never described what happened are now refused instead of ' + + 'silently honoured. The #11825 keep itself stands: `HotReloadConfigSchema`, ' + + '`PluginStateSnapshotSchema` and the health vocabularies still export from ' + + '`./kernel`, and `HotReloadManager` / `PluginHealthMonitor` still export ' + + 'from `@objectstack/core` with their tests green.', + }, { id: 'identity-api-key-schema-retired', surface: @@ -8020,6 +8090,42 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8586 / PR #8702 precedent). 'kernel/AdvancedPluginLifecycleConfig', + // #12340 — kernel/plugin-lifecycle-advanced.zod.ts + // `DistributedStateConfigSchema`, retired whole (ADR-0049 enforce-or-remove). + // + // The orphan value schema of `HotReloadConfig.distributedConfig`, which had + // ZERO readers — measured at cdbd9204b6 with a firing positive control + // (`stateStrategy` resolves to real readers in `core/src/hot-reload.ts`, so + // the scan sees readers; `distributedConfig` resolved to nothing outside + // `packages/spec` itself in objectstack, and to nothing in objectui). An + // author could name a Redis or etcd endpoint, a key prefix, a TTL, auth + // credentials and a replication factor, and NOTHING ever opened a connection + // — the #3950 shape, sharpened by cluster-persistence vocabulary an AI author + // (ADR-0033) reads as proof the capability exists. + // + // It leaves with the enum value it existed for. `HotReloadConfig.stateStrategy` + // narrowed to ['memory','none'] in the same card, because the 'disk' and + // 'distributed' arms of `PluginStateManager.saveState` both wrote to the same + // in-memory Map as 'memory' and said so at DEBUG level only. With + // 'distributed' gone, a key whose own doc comment called it "required when + // stateStrategy is 'distributed'" names a value that no longer exists — it + // could not honestly outlive the narrowing. + // + // This is the #11825 keep, narrowed from inside, NOT reversed: that ruling + // kept `HotReloadConfigSchema` and `HotReloadManager` as a host-driven library + // and they both stand here. What it also listed among the survivors was + // `DistributedStateConfig` — a line this card reverses on new evidence, since + // #11825 measured the CONTAINER's six groups and never this key's own readers. + // The survival pin in `plugin-lifecycle-advanced-retirement.test.ts` moves in + // the same commit, deliberately and with the reasoning recorded there. + // + // Route 3: `HotReloadConfig` is not an authorable surface — no metadata-type + // binding, stack collection or manifest embed ever carried it, and nothing in + // the tree parses `HotReloadConfigSchema` outside its own unit test — so there + // is no authored document for a D2 conversion to rewrite and nobody who could + // receive a parse-time tombstone. This table plus the D3 semantic entry + // `hot-reload-inert-state-strategies-retired` ARE the declaration. + 'kernel/DistributedStateConfig', // #11825 — `kernel/GracefulDegradation` left with // `kernel/AdvancedPluginLifecycleConfig`: its ONLY consumer was the retired // container's `degradation` key (the #3950 rule — an exported value schema