Skip to content

Commit 4635f3e

Browse files
os-warrenclaude
andauthored
fix(spec,core): HotReloadConfig.stateStrategy refuses the two values it never implemented; distributedConfig retired (#12340) (#12425)
* fix(spec,core): stateStrategy refuses the two values it never implemented; distributedConfig retired (#12340) ADR-0049 enforce-or-remove, applied one level inside the host-driven library the 2026-08-25 #11825 ruling kept. 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; distributedConfig had zero readers anywhere. - spec: stateStrategy narrowed to ['memory','none'] with the prescription on the enum's own error map (invisible to the four ratchets by construction); distributedConfig and DistributedStateConfigSchema removed (route 3 — no authored document, nothing parses this schema). - core: registerPlugin refuses an unhonoured stateStrategy and a leftover distributedConfig with an ADR-0112 envelope, before the enabled check. - ADR-0087: RETIRED_DEFS_BY_MAJOR[18] + D3 semantic entry. - the #11825 survivor pin for DistributedStateConfigSchema moves here deliberately, with the reasoning recorded beside it. Part of #12340 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * test(spec): assert the prescription's actual plural spelling ('were removed') The refusal fired correctly; the assertion named the singular. Caught by the run, not by reading. Part of #12340 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * chore(spec): regenerate strictness-ledger counts after the DistributedStateConfig removal kernel/ 277 -> 274, fully accounted for: the removed DistributedStateConfigSchema carried exactly three z.object nodes (the def plus its inline auth and replication sub-objects). The prose ledger has no per-file verdict for plugin-lifecycle-advanced.zod.ts, so no Class verdict needed re-examining. Part of #12340 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * chore(spec): regenerate the strictness-ledger counts from the merged tree The merge of `main` into this branch left the `kernel/` count as OURS (274) — the `merge=os-regen` driver defers rather than text-merging, because a generator run mid-merge reads a half-merged tree. Regenerated from the fully merged sources, as that driver's pending marker requires. 277 at the merge base; this branch's DistributedStateConfig removal takes 3 (the def plus its inline `auth` and `replication` objects) and #12417's `contributes.routes` retirement on main takes 1, so 273 is the composition of both sides, not a choice between them. Predicted before the run; observed exactly. `check:strictness-ledger` green. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent abc12cb commit 4635f3e

16 files changed

Lines changed: 698 additions & 199 deletions
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
---
5+
6+
fix(spec,core): `HotReloadConfig.stateStrategy` refuses the two values it never implemented; `distributedConfig` retired (#12340, ADR-0049)
7+
8+
<!-- adr-0087: registered hot-reload-inert-state-strategies-retired -->
9+
10+
**BREAKING** accept-set narrowing + export removal, landing after the v17.0.0
11+
cut (the lockstep launch-window convention ships it as `minor`; the
12+
prescription is registered under protocol major 18 —
13+
`RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry
14+
`hot-reload-inert-state-strategies-retired` — where `os migrate meta` users
15+
will look).
16+
17+
This is ADR-0049 applied one level INSIDE the library the 2026-08-25 #11825
18+
ruling deliberately kept. That ruling retired the authorable lifecycle-config
19+
container and kept `HotReloadConfigSchema` as a host-driven library parameter
20+
type; this change measures the kept vocabulary's own remainder and finds the
21+
same defect in it. The keep itself stands — `HotReloadConfigSchema`,
22+
`PluginStateSnapshotSchema` and the health vocabularies still export, and
23+
`HotReloadManager` / `PluginHealthMonitor` are untouched.
24+
25+
The `'disk'` and `'distributed'` arms of `PluginStateManager.saveState` both
26+
wrote to the SAME in-memory `Map` as `'memory'` — the in-source comments said
27+
"memory fallback" — and announced the substitution at DEBUG level only. A host
28+
that asked for durable or cluster-replicated state got process-local memory
29+
and no error: state that does not survive the restart it was configured to
30+
survive. `distributedConfig` had ZERO readers anywhere, so an author could
31+
name a Redis endpoint, a TTL and a replication factor and nothing ever opened
32+
a connection.
33+
34+
FROM → TO:
35+
36+
- `stateStrategy: 'disk'``stateStrategy: 'memory'` — byte-identical runtime
37+
behaviour, because `'disk'` already stored to memory. It is the spelling
38+
that was false, not the behaviour.
39+
- `stateStrategy: 'distributed'``stateStrategy: 'memory'` — same, or
40+
`'none'` to disable state preservation outright.
41+
- `distributedConfig: { … }`*(removed)* — delete the key. It left with the
42+
`'distributed'` value its own doc comment called it "required" for.
43+
- `DistributedStateConfigSchema` / `DistributedStateConfig` /
44+
`DistributedStateConfigParsed`*(removed)* — the orphan value schema of
45+
that one key.
46+
47+
One-line fix: replace `'disk'` or `'distributed'` with `'memory'` and delete
48+
any `distributedConfig` — you were already getting in-memory state. There is
49+
no in-tree replacement for durable or distributed plugin state; persist it in
50+
the host, which owns the process lifetime these strategies pretended to
51+
outlive. Real disk or distributed persistence returns only via the ENFORCE
52+
route of ADR-0049 — the implementation first, the declaration with it.
53+
54+
The retirement kit:
55+
56+
- **enum-value narrowing** (`['memory','disk','distributed','none']`
57+
`['memory','none']`): invisible to all four ratchets by construction (the
58+
def still emits), so the prescription hangs on the enum's own `error` map
59+
dispatched by `issue.input` — the `crypto.hash` / `managedBy: 'system'`
60+
precedent. A value that was never legal still gets zod's own enum message,
61+
so a typo is not told it "was removed".
62+
- **whole-def deletion** (route 3 — `HotReloadConfig` is not an authorable
63+
surface: no metadata-type binding, stack collection or manifest embed ever
64+
carried it, and nothing in the tree parses `HotReloadConfigSchema` outside
65+
its own unit test, so there is no authored document to rewrite and nobody
66+
who could receive a parse-time tombstone): `kernel/DistributedStateConfig`
67+
in `RETIRED_DEFS_BY_MAJOR[18]` plus the D3 semantic entry. Ratchets moved as
68+
a def removal must — `api-surface` −3, `authorable-surface` −8,
69+
`json-schema.manifest` −1.
70+
- **runtime doors** in `@objectstack/core`, because route 3 leaves no
71+
parse-time prescription: `HotReloadManager.registerPlugin` now refuses an
72+
unhonoured `stateStrategy` and a leftover `distributedConfig` with an
73+
ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`) carrying the
74+
prescription. Refused BEFORE the `enabled` check, so a disabled config
75+
cannot smuggle the false declaration through. TypeScript hosts never reach
76+
it — `HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, a
77+
compile error at the call site.
78+
- **pin move, declared**: `DistributedStateConfigSchema` was NAMED in the
79+
#11825 survivor list, so this reverses one line of that ruling on new
80+
evidence — #11825 measured the container's six groups, never this key's own
81+
readers. The pin in `kernel/plugin-lifecycle-advanced-retirement.test.ts`
82+
moves in the same commit with the reasoning recorded beside it, and asserts
83+
the surrounding keep is intact.
84+
- zero in-tree consumers passed `'disk'` or `'distributed'` (measured at
85+
cdbd9204b6 with a firing positive control; every live caller passes
86+
`'memory'` or `'none'`), so no in-repo source changes ride along.

content/docs/references/index.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Protocol Reference
3-
description: Every schema published by @objectstack/spec — 1586 schemas across 14 protocol modules
3+
description: Every schema published by @objectstack/spec — 1585 schemas across 14 protocol modules
44
---
55

66
{/* ⚠️ 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
2626
| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
2727
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
2828
| [Integration Protocol](/docs/references/integration) | 1 | 27 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. |
29-
| [Kernel Protocol](/docs/references/kernel) | 31 | 173 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. |
29+
| [Kernel Protocol](/docs/references/kernel) | 31 | 172 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. |
3030
| [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. |
3131
| [Security Protocol](/docs/references/security) | 5 | 27 | Permission sets, row-level security, sharing rules, tenancy posture. |
3232
| [Shared Protocol](/docs/references/shared) | 8 | 32 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
3333
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3434
| [System Protocol](/docs/references/system) | 36 | 288 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3535
| [UI Protocol](/docs/references/ui) | 16 | 152 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
36-
| **Total** | **199** | **1586** | 14 protocol modules |
36+
| **Total** | **199** | **1585** | 14 protocol modules |
3737

3838
---
3939

@@ -215,7 +215,7 @@ The single connector protocol (ADR-0097) — catalog descriptors and provider-bo
215215

216216
## Kernel Protocol
217217

218-
**Source:** `packages/spec/src/kernel/` · **Import:** `@objectstack/spec/kernel` · **31 pages, 173 schemas**
218+
**Source:** `packages/spec/src/kernel/` · **Import:** `@objectstack/spec/kernel` · **31 pages, 172 schemas**
219219

220220
Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry.
221221

@@ -242,7 +242,7 @@ Plugin lifecycle and manifests, capabilities and security, metadata loading, ser
242242
| [`package-upgrade.zod.ts`](/docs/references/kernel/package-upgrade) | `MetadataChangeType`, `MetadataDiffItem`, `RollbackPackageRequest`, `RollbackPackageResponse`, `UpgradeImpactLevel`, `UpgradePackageRequest`, `UpgradePackageResponse`, `UpgradePhase`, `UpgradePlan`, `UpgradeSnapshot` |
243243
| [`plugin.zod.ts`](/docs/references/kernel/plugin) | `Plugin` |
244244
| [`plugin-capability.zod.ts`](/docs/references/kernel/plugin-capability) | `CapabilityConformanceLevel`, `ExtensionPoint`, `PluginCapability`, `PluginCapabilityManifest`, `PluginDependency`, `PluginInterface`, `ProtocolFeature`, `ProtocolReference`, `ProtocolVersion` |
245-
| [`plugin-lifecycle-advanced.zod.ts`](/docs/references/kernel/plugin-lifecycle-advanced) | `DistributedStateConfig`, `HotReloadConfig`, `PluginHealthCheck`, `PluginHealthReport`, `PluginHealthStatus`, `PluginStateSnapshot` |
245+
| [`plugin-lifecycle-advanced.zod.ts`](/docs/references/kernel/plugin-lifecycle-advanced) | `HotReloadConfig`, `PluginHealthCheck`, `PluginHealthReport`, `PluginHealthStatus`, `PluginStateSnapshot` |
246246
| [`plugin-loading.zod.ts`](/docs/references/kernel/plugin-loading) | `PluginLoadingEvent`, `PluginLoadingState` |
247247
| [`plugin-registry.zod.ts`](/docs/references/kernel/plugin-registry) | `PluginInstallConfig`, `PluginQualityMetrics`, `PluginRegistryEntry`, `PluginSearchFilters`, `PluginStatistics`, `PluginVendor` |
248248
| [`plugin-security.zod.ts`](/docs/references/kernel/plugin-security) | `DependencyGraph`, `DependencyGraphNode`, `PackageDependencyConflict`, `PackageDependencyResolutionResult`, `PluginProvenance`, `PluginTrustScore`, `ResolvedPackageDependency`, `SBOM`, `SBOMEntry`, `SecurityPolicy`, `SecurityScanResult`, `SecurityVulnerability`, `VulnerabilitySeverity` |

content/docs/references/kernel/plugin-lifecycle-advanced.mdx

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -25,30 +25,13 @@ the #11825 retirement record below.
2525
## TypeScript Usage
2626

2727
```typescript
28-
import { DistributedStateConfigSchema, HotReloadConfigSchema, PluginHealthCheckSchema, PluginHealthReportSchema, PluginHealthStatusSchema, PluginStateSnapshotSchema } from '@objectstack/spec/kernel';
29-
import type { DistributedStateConfig, HotReloadConfig, PluginHealthCheck, PluginHealthReport, PluginHealthStatus, PluginStateSnapshot } from '@objectstack/spec/kernel';
28+
import { HotReloadConfigSchema, PluginHealthCheckSchema, PluginHealthReportSchema, PluginHealthStatusSchema, PluginStateSnapshotSchema } from '@objectstack/spec/kernel';
29+
import type { HotReloadConfig, PluginHealthCheck, PluginHealthReport, PluginHealthStatus, PluginStateSnapshot } from '@objectstack/spec/kernel';
3030

3131
// Validate data
32-
const result = DistributedStateConfigSchema.parse(data);
32+
const result = HotReloadConfigSchema.parse(data);
3333
```
3434

35-
---
36-
37-
## DistributedStateConfig
38-
39-
### Properties
40-
41-
| Property | Type | Required | Description |
42-
| :--- | :--- | :--- | :--- |
43-
| **provider** | `Enum<'redis' \| 'etcd' \| 'custom'>` || Distributed state backend provider |
44-
| **endpoints** | `string[]` | optional | Backend connection endpoints |
45-
| **keyPrefix** | `string` | optional | Prefix for all keys (e.g., "plugin:my-plugin:") |
46-
| **ttl** | `integer` | optional | State expiration time in seconds |
47-
| **auth** | `{ username?: string; password?: string; token?: string; certificate?: string }` | optional | |
48-
| **replication** | `{ enabled: boolean; minReplicas: integer }` | optional | |
49-
| **customConfig** | `Record<string, any>` | optional | Provider-specific configuration |
50-
51-
5235
---
5336

5437
## HotReloadConfig
@@ -61,24 +44,11 @@ const result = DistributedStateConfigSchema.parse(data);
6144
| **watchPatterns** | `string[]` | optional | Glob patterns to watch for changes |
6245
| **debounceDelay** | `integer` | optional (default: `1000`) | Wait time after change detection before reload |
6346
| **preserveState** | `boolean` | optional (default: `true`) | Keep plugin state across reloads |
64-
| **stateStrategy** | `Enum<'memory' \| 'disk' \| 'distributed' \| 'none'>` | optional (default: `"memory"`) | How to preserve state during reload |
65-
| **distributedConfig** | `{ provider: Enum<'redis' \| 'etcd' \| 'custom'>; endpoints?: string[]; keyPrefix?: string; ttl?: integer; … }` | optional | Configuration for distributed state management |
47+
| **stateStrategy** | `Enum<'memory' \| 'none'>` | optional (default: `"memory"`) | How to preserve state during reload |
6648
| **shutdownTimeout** | `integer` | optional (default: `30000`) | Maximum time to wait for graceful shutdown |
6749
| **beforeReload** | `string[]` | optional | Hook names to call before reload |
6850
| **afterReload** | `string[]` | optional | Hook names to call after reload |
6951

70-
### Nested Shape: `HotReloadConfig.distributedConfig`
71-
72-
| Property | Type | Required | Description |
73-
| :--- | :--- | :--- | :--- |
74-
| **provider** | `Enum<'redis' \| 'etcd' \| 'custom'>` || Distributed state backend provider |
75-
| **endpoints** | `string[]` | optional | Backend connection endpoints |
76-
| **keyPrefix** | `string` | optional | Prefix for all keys (e.g., "plugin:my-plugin:") |
77-
| **ttl** | `integer` | optional | State expiration time in seconds |
78-
| **auth** | `{ username?: string; password?: string; token?: string; certificate?: string }` | optional | |
79-
| **replication** | `{ enabled: boolean; minReplicas: integer }` | optional | |
80-
| **customConfig** | `Record<string, any>` | optional | Provider-specific configuration |
81-
8252

8353
---
8454

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ directory rather than per file.
261261
| `cloud/` | 83 |
262262
| `identity/` | 32 |
263263
| `integration/` | 10 |
264-
| `kernel/` | 276 |
264+
| `kernel/` | 273 |
265265
| `qa/` | 6 |
266266
| `shared/` | 20 |
267267
| `system/` | 361 |

packages/core/src/hot-reload.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,123 @@ describe('HotReloadManager', () => {
208208
});
209209
});
210210
});
211+
212+
213+
// ── [#12340] `stateStrategy` refuses what it cannot honour ──────────────────
214+
//
215+
// Before this card, `registerPlugin` accepted 'disk' and 'distributed' and
216+
// `saveState` wrote both to the same in-memory Map as 'memory', reporting the
217+
// substitution at DEBUG level only. A host that asked for durable or
218+
// cluster-replicated state got process-local memory and no error — state that
219+
// does not survive the restart it was configured to survive.
220+
//
221+
// Every assertion below is about observable behaviour: the refusal envelope,
222+
// the prescription's load-bearing facts, and the fact that the honoured
223+
// strategies still work. None of them asserts the source.
224+
describe('[#12340] stateStrategy refusal', () => {
225+
const configWith = (strategy: string): HotReloadConfigParsed =>
226+
({
227+
enabled: true,
228+
debounceDelay: 0,
229+
preserveState: true,
230+
stateStrategy: strategy,
231+
shutdownTimeout: 1000,
232+
}) as unknown as HotReloadConfigParsed;
233+
234+
let mgr: HotReloadManager;
235+
beforeEach(() => {
236+
mgr = new HotReloadManager(createRecordingLogger([]));
237+
});
238+
239+
for (const retired of ['disk', 'distributed']) {
240+
it(`refuses '${retired}' at registration, with an ADR-0112 envelope`, () => {
241+
let caught: (Error & { code?: string; status?: number }) | undefined;
242+
try {
243+
mgr.registerPlugin('p', configWith(retired));
244+
} catch (e) {
245+
caught = e as Error & { code?: string; status?: number };
246+
}
247+
248+
// The envelope, not merely "it threw" — a bare toThrow() would stay
249+
// green against any unrelated failure on this path.
250+
expect(caught, `'${retired}' must be refused`).toBeDefined();
251+
expect(caught?.code).toBe('VALIDATION_ERROR');
252+
expect(caught?.status).toBe(400);
253+
254+
// The prescription's load-bearing facts. Pinned by CONTENT, never by
255+
// byte-equality with the spec-side string: the two answer different
256+
// doors (parse vs registration) and are deliberately not shared.
257+
const m = caught?.message ?? '';
258+
expect(m).toContain(retired);
259+
expect(m).toContain('#12340');
260+
expect(m).toContain('ADR-0049');
261+
expect(m).toContain('were removed');
262+
expect(m).toContain("Use 'memory'");
263+
expect(m).toContain('p'); // locates the offending plugin
264+
});
265+
}
266+
267+
it('refuses an unknown strategy WITHOUT claiming it was retired', () => {
268+
// Anti-vacuity: a typo must not be told it "was removed" — that misinforms
269+
// the author of `dsik`, who never had a working config to migrate from.
270+
let caught: (Error & { code?: string }) | undefined;
271+
try {
272+
mgr.registerPlugin('p', configWith('dsik'));
273+
} catch (e) {
274+
caught = e as Error & { code?: string };
275+
}
276+
expect(caught).toBeDefined();
277+
expect(caught?.code).toBe('VALIDATION_ERROR');
278+
expect(caught?.message).not.toContain('were removed');
279+
expect(caught?.message).toContain('never been implemented');
280+
});
281+
282+
it("refuses a leftover 'distributedConfig' instead of silently ignoring it", () => {
283+
// The schema is not .strict(), so zod would STRIP this key on any parse
284+
// path — a clean parse and a setting that never takes effect. #12340 took
285+
// route 3 (no tombstone: nothing parses this schema), so THIS is the door
286+
// that keeps the removal honest for the audience that exists.
287+
const cfg = {
288+
...configWith('memory'),
289+
distributedConfig: { provider: 'redis', endpoints: ['redis://localhost:6379'] },
290+
} as unknown as HotReloadConfigParsed;
291+
292+
let caught: (Error & { code?: string; status?: number }) | undefined;
293+
try {
294+
mgr.registerPlugin('p', cfg);
295+
} catch (e) {
296+
caught = e as Error & { code?: string; status?: number };
297+
}
298+
expect(caught).toBeDefined();
299+
expect(caught?.code).toBe('VALIDATION_ERROR');
300+
expect(caught?.status).toBe(400);
301+
expect(caught?.message).toContain('distributedConfig');
302+
expect(caught?.message).toContain('#12340');
303+
expect(caught?.message).toContain('nothing ever read it');
304+
});
305+
306+
it('refuses even when hot reload is disabled', () => {
307+
// The door must not depend on `enabled`: a false declaration is false
308+
// whether or not the feature is switched on.
309+
const cfg = { ...configWith('disk'), enabled: false } as HotReloadConfigParsed;
310+
expect(() => mgr.registerPlugin('p', cfg)).toThrow(/#12340/);
311+
});
312+
313+
for (const live of ['memory', 'none'] as const) {
314+
it(`still registers and reloads with '${live}'`, async () => {
315+
const cfg = configWith(live);
316+
expect(() => mgr.registerPlugin('p', cfg)).not.toThrow();
317+
318+
const plugin = {
319+
name: 'p', version: '1.0.0', init: () => {}, destroy: async () => {},
320+
} as unknown as Plugin;
321+
let restored: Record<string, unknown> | undefined;
322+
const ok = await mgr.reloadPlugin(
323+
'p', plugin, '1.0.0', () => ({ hello: 'world' }), (st) => { restored = st; }
324+
);
325+
expect(ok).toBe(true);
326+
// 'memory' preserves state across the reload; 'none' deliberately does not.
327+
expect(restored).toEqual(live === 'memory' ? { hello: 'world' } : undefined);
328+
});
329+
}
330+
});

0 commit comments

Comments
 (0)