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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .changeset/hot-reload-inert-state-strategies-retired.md
Original file line numberDiff line numberDiff line change
@@ -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)

<!-- adr-0087: registered hot-reload-inert-state-strategies-retired -->

**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.
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line numberDiff line numberDiff line change
@@ -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/. */}
Expand All@@ -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 |

---

Expand DownExpand Up@@ -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.

Expand All@@ -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` |
Expand Down
38 changes: 4 additions & 34 deletions content/docs/references/kernel/plugin-lifecycle-advanced.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, any>` | optional | Provider-specific configuration |


---

## HotReloadConfig
Expand All@@ -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<string, any>` | optional | Provider-specific configuration |


---

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
120 changes: 120 additions & 0 deletions packages/core/src/hot-reload.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown> | 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);
});
}
});
Loading
Loading