Skip to content

Commit fc3fdb1

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22379-permission-sets-packaged-lock
2 parents 283d5e8 + 1915434 commit fc3fdb1

4 files changed

Lines changed: 347 additions & 4 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core): `ObjectKernelConfig.bootPhaseHooks`, a declared option to bootstrap without dispatching `kernel:ready`, `kernel:bootstrapped` and `kernel:listening`
6+
7+
Clause-②: yes (widening)
8+
9+
- **What is new.** `new ObjectKernel({ bootPhaseHooks: false })` runs dependency ordering, every plugin's `init()`, the core-service fallbacks, every plugin's `start()` and the system-requirement check exactly as before. It then stops without dispatching the three boot-phase hooks, and the kernel is `running`. Only an explicit `false` withholds them. An absent option or `undefined` keeps today's boot, unchanged. `Runtime` forwards the option through its existing `kernel` config.
10+
- **Who it is for.** A host that needs a kernel's registered definitions and started services, and nothing the boot phase does. The example is a repair kernel for an environment whose normal boot cannot finish because a boot-phase handler throws, never settles, or outlasts the host's timeout. Such a host no longer has to wrap the kernel's private `context.hook` to drop those names.
11+
- **What it guarantees.** Every service and every definition a plugin registers in `init()` or `start()` is present, a driver it connects there included. `shutdown()` is unchanged: `kernel:shutdown`, every `destroy()` and every `onShutdown()` handler run.
12+
- **What it does not guarantee.** That the definitions equal a full boot's. A definition a plugin registers in a boot-phase handler is absent, and nothing a plugin does in those handlers runs: seeds, reconciles, schedulers, audits, and the HTTP listener a server plugin opens on `kernel:listening`. Whether a given set of plugins registers anything there is a reading of that set, not a property of the option. The option withholds the kernel's own dispatch, not the hook names.
13+
- **Loud, once.** A withholding boot logs one `warn` naming each withheld hook and how many registered handlers it left uncalled, with the counts also in the line's `withheldHandlers` field. Its completion line says the hooks were withheld.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, afterEach } from 'vitest';
4+
import type { Mock } from 'vitest';
5+
import { ObjectKernel } from './kernel';
6+
import type { ObjectKernelConfig } from './kernel';
7+
import type { Plugin } from './types';
8+
9+
/**
10+
* `ObjectKernelConfig.bootPhaseHooks` — a kernel bootstrapped WITHOUT the three
11+
* boot-phase hooks, for a host that needs the registered definitions and the
12+
* started services and nothing the boot phase does (a repair kernel for an
13+
* environment whose normal boot cannot finish).
14+
*
15+
* Both halves of the option's docblock are pinned here, against one
16+
* composition:
17+
*
18+
* - what it GUARANTEES: a definition a plugin registers in `init()` or
19+
* `start()` is present, and the kernel is running;
20+
* - what it does NOT: a definition a plugin registers in a `kernel:ready`
21+
* handler is absent under the option and present without it. "No ready
22+
* handler registers a definition" is a reading of one set of plugins,
23+
* never a property of the option, so the absent half is a pin, not a
24+
* footnote.
25+
*
26+
* The controls run the same composition with the option absent, `true` and
27+
* `undefined`: today's boot, unchanged.
28+
*/
29+
30+
const BOOT_PHASE_HOOKS = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;
31+
type BootPhaseHook = (typeof BOOT_PHASE_HOOKS)[number];
32+
33+
/** The "definition registry" the composition's plugins write to. */
34+
type Definitions = Map<string, { registeredIn: string }>;
35+
36+
const REGISTRY = 'test.definition-registry';
37+
38+
function newKernel(extra: ObjectKernelConfig = {}): ObjectKernel {
39+
return new ObjectKernel({
40+
logger: { level: 'error' },
41+
gracefulShutdown: false,
42+
skipSystemValidation: true,
43+
...extra,
44+
});
45+
}
46+
47+
/**
48+
* A registry plugin plus a declarer that registers one definition per place a
49+
* plugin can register one — `init()`, `start()`, a `kernel:ready` handler —
50+
* and a spy on each of the three boot-phase hooks.
51+
*/
52+
function composition() {
53+
const spies: Record<BootPhaseHook, Mock<() => void>> = {
54+
'kernel:ready': vi.fn<() => void>(),
55+
'kernel:bootstrapped': vi.fn<() => void>(),
56+
'kernel:listening': vi.fn<() => void>(),
57+
};
58+
const order: string[] = [];
59+
60+
const registry: Plugin = {
61+
name: REGISTRY,
62+
version: '1.0.0',
63+
init: async (ctx) => {
64+
ctx.registerService('definitions', new Map() as Definitions);
65+
},
66+
};
67+
68+
const declarer: Plugin = {
69+
name: 'test.declarer',
70+
version: '1.0.0',
71+
dependencies: [REGISTRY],
72+
init: async (ctx) => {
73+
const definitions = ctx.getService<Definitions>('definitions');
74+
definitions.set('obj_from_init', { registeredIn: 'init' });
75+
ctx.hook('kernel:ready', async () => {
76+
definitions.set('obj_from_ready', { registeredIn: 'kernel:ready' });
77+
});
78+
for (const hook of BOOT_PHASE_HOOKS) {
79+
ctx.hook(hook, async () => {
80+
order.push(hook);
81+
spies[hook]();
82+
});
83+
}
84+
},
85+
start: async (ctx) => {
86+
ctx.getService<Definitions>('definitions').set('obj_from_start', { registeredIn: 'start' });
87+
},
88+
};
89+
90+
return { plugins: [registry, declarer], spies, order };
91+
}
92+
93+
async function boot(kernel: ObjectKernel, plugins: Plugin[]): Promise<Definitions> {
94+
for (const plugin of plugins) await kernel.use(plugin);
95+
await kernel.bootstrap();
96+
return kernel.getService<Definitions>('definitions');
97+
}
98+
99+
const spyOnLog = (k: ObjectKernel, level: 'warn' | 'info') =>
100+
vi.spyOn((k as unknown as { logger: Record<'warn' | 'info', (...a: unknown[]) => void> }).logger, level);
101+
102+
/** The `withheldHandlers` meta of every `warn` the withholding boot emitted. */
103+
function withheldReports(warnSpy: ReturnType<typeof spyOnLog>): unknown[] {
104+
return warnSpy.mock.calls
105+
.map((call) => (call[1] as { withheldHandlers?: unknown } | undefined)?.withheldHandlers)
106+
.filter((meta) => meta !== undefined);
107+
}
108+
109+
describe('ObjectKernel bootPhaseHooks', () => {
110+
const kernels: ObjectKernel[] = [];
111+
const track = (k: ObjectKernel) => { kernels.push(k); return k; };
112+
113+
afterEach(async () => {
114+
for (const k of kernels.splice(0)) {
115+
if (k.isRunning()) await k.shutdown();
116+
}
117+
vi.restoreAllMocks();
118+
});
119+
120+
describe('bootPhaseHooks: false', () => {
121+
it('keeps every definition registered in init() and start(), and the kernel runs', async () => {
122+
const { plugins } = composition();
123+
const kernel = track(newKernel({ bootPhaseHooks: false }));
124+
125+
const definitions = await boot(kernel, plugins);
126+
127+
expect(definitions.get('obj_from_init')).toEqual({ registeredIn: 'init' });
128+
expect(definitions.get('obj_from_start')).toEqual({ registeredIn: 'start' });
129+
expect(kernel.isRunning()).toBe(true);
130+
expect(kernel.getState()).toBe('running');
131+
});
132+
133+
it('leaves a definition registered in a kernel:ready handler absent', async () => {
134+
const { plugins } = composition();
135+
const kernel = track(newKernel({ bootPhaseHooks: false }));
136+
137+
const definitions = await boot(kernel, plugins);
138+
139+
expect(definitions.has('obj_from_ready')).toBe(false);
140+
expect([...definitions.keys()].sort()).toEqual(['obj_from_init', 'obj_from_start']);
141+
});
142+
143+
it('dispatches none of kernel:ready, kernel:bootstrapped, kernel:listening', async () => {
144+
const { plugins, spies, order } = composition();
145+
const kernel = track(newKernel({ bootPhaseHooks: false }));
146+
147+
await boot(kernel, plugins);
148+
149+
for (const hook of BOOT_PHASE_HOOKS) {
150+
expect(spies[hook], hook).not.toHaveBeenCalled();
151+
}
152+
expect(order).toEqual([]);
153+
});
154+
155+
// The case the option exists for: a boot-phase handler that throws, or
156+
// never settles, no longer decides whether the definitions are reachable.
157+
it('completes the boot when a kernel:ready handler would throw or never settle', async () => {
158+
const kernel = track(newKernel({ bootPhaseHooks: false }));
159+
const stuck: Plugin = {
160+
name: 'test.stuck-boot-phase',
161+
version: '1.0.0',
162+
init: async (ctx) => {
163+
ctx.hook('kernel:ready', async () => { throw new Error('seed heal failed'); });
164+
ctx.hook('kernel:bootstrapped', () => new Promise<void>(() => { /* never settles */ }));
165+
},
166+
};
167+
168+
await kernel.use(stuck);
169+
await expect(kernel.bootstrap()).resolves.toBeUndefined();
170+
expect(kernel.getState()).toBe('running');
171+
});
172+
173+
it('reports the withheld handlers once, per hook', async () => {
174+
const { plugins } = composition();
175+
const kernel = track(newKernel({ bootPhaseHooks: false }));
176+
const warnSpy = spyOnLog(kernel, 'warn');
177+
178+
await boot(kernel, plugins);
179+
180+
// The declarer registers two kernel:ready handlers (the definition
181+
// and the spy) and one each on the other two.
182+
expect(withheldReports(warnSpy)).toEqual([
183+
{ 'kernel:ready': 2, 'kernel:bootstrapped': 1, 'kernel:listening': 1 },
184+
]);
185+
});
186+
187+
it('shuts down cleanly: kernel:shutdown, destroy() and onShutdown run, nothing waits on a withheld hook', async () => {
188+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
189+
const { plugins, spies } = composition();
190+
const reached: string[] = [];
191+
const teardown: Plugin = {
192+
name: 'test.teardown',
193+
version: '1.0.0',
194+
init: async (ctx) => {
195+
ctx.hook('kernel:shutdown', async () => { reached.push('kernel:shutdown'); });
196+
},
197+
destroy: async () => { reached.push('destroy'); },
198+
};
199+
// A short guard: a teardown that waited on something a withheld
200+
// hook would have resolved trips it and calls process.exit(1).
201+
const kernel = newKernel({ bootPhaseHooks: false, shutdownTimeout: 2_000 });
202+
kernel.onShutdown(async () => { reached.push('onShutdown'); });
203+
204+
await boot(kernel, [...plugins, teardown]);
205+
await kernel.shutdown();
206+
207+
expect(reached).toEqual(['kernel:shutdown', 'destroy', 'onShutdown']);
208+
expect(kernel.getState()).toBe('stopped');
209+
expect(exitSpy).not.toHaveBeenCalled();
210+
for (const hook of BOOT_PHASE_HOOKS) {
211+
expect(spies[hook], hook).not.toHaveBeenCalled();
212+
}
213+
});
214+
});
215+
216+
// Controls: the same composition on today's boot. Only an explicit `false`
217+
// withholds; an absent option and an explicit `undefined` (a host
218+
// forwarding an unset value) are the default.
219+
describe.each([
220+
['absent', {}],
221+
['true', { bootPhaseHooks: true }],
222+
['undefined', { bootPhaseHooks: undefined }],
223+
] as Array<[string, ObjectKernelConfig]>)('bootPhaseHooks %s (control)', (_label, extra) => {
224+
it('fires all three hooks in order and keeps every definition, the kernel:ready one included', async () => {
225+
const { plugins, spies, order } = composition();
226+
const kernel = track(newKernel(extra));
227+
const warnSpy = spyOnLog(kernel, 'warn');
228+
229+
const definitions = await boot(kernel, plugins);
230+
231+
expect(order).toEqual([...BOOT_PHASE_HOOKS]);
232+
for (const hook of BOOT_PHASE_HOOKS) {
233+
expect(spies[hook], hook).toHaveBeenCalledTimes(1);
234+
}
235+
expect([...definitions.keys()].sort()).toEqual(['obj_from_init', 'obj_from_ready', 'obj_from_start']);
236+
expect(withheldReports(warnSpy)).toEqual([]);
237+
expect(kernel.isRunning()).toBe(true);
238+
});
239+
});
240+
});

‎packages/core/src/kernel.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,16 @@ import { raceWithTimeout } from './timeout-guard.js';
6060
*/
6161
const DEGRADED_CAPABILITIES_SERVICE = 'kernel.degraded-capabilities';
6262

63+
/**
64+
* The three hooks `bootstrap()` dispatches once every plugin's `init()` and
65+
* `start()` has run, in this order — the hooks
66+
* {@link ObjectKernelConfig.bootPhaseHooks} withholds. Read only to NAME them in
67+
* the line a withholding boot logs; the dispatch sites in `bootstrap()` keep
68+
* their string literals, which is what `scripts/check-kernel-hook-pairs.mjs`
69+
* reads.
70+
*/
71+
const BOOT_PHASE_HOOKS = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const;
72+
6373
/**
6474
* Enhanced Kernel Configuration
6575
*/
@@ -85,6 +95,51 @@ export interface ObjectKernelConfig {
8595

8696
/** Whether to skip strict system requirement validation (Critical for testing) */
8797
skipSystemValidation?: boolean;
98+
99+
/**
100+
* Whether `bootstrap()` dispatches the three BOOT-PHASE hooks —
101+
* `kernel:ready`, `kernel:bootstrapped` and `kernel:listening` — once every
102+
* plugin's `init()` and `start()` has run. Default `true`: the boot is
103+
* exactly what it has always been. Only an explicit `false` withholds them;
104+
* absent or `undefined` is the default.
105+
*
106+
* `false` is for a host that needs a kernel's registered DEFINITIONS and
107+
* its started services, and nothing the boot phase does — for example a
108+
* repair kernel built for an environment whose normal boot cannot finish,
109+
* because a boot-phase handler throws, never settles, or outlasts the
110+
* host's own timeout.
111+
*
112+
* **What `false` withholds:** the kernel's dispatch of those three hooks,
113+
* and nothing else. `PluginContext.hook` still accepts a handler for them;
114+
* the handler is never called. So whatever a plugin does in such a handler
115+
* does not happen on this kernel: a definition it registers there is
116+
* ABSENT, the seeds, heals, reconciles, replays, schedulers and audits it
117+
* runs there do not run, and an HTTP server that opens its socket on
118+
* `kernel:listening` never listens. The boot logs one `warn` naming each
119+
* withheld hook and how many registered handlers it left uncalled, and its
120+
* completion line says the hooks were withheld.
121+
*
122+
* **What it guarantees:** the rest of `bootstrap()` runs unchanged —
123+
* dependency ordering, every plugin's `init()`, the core-service
124+
* fallbacks, every plugin's `start()` under the same timeout and rollback
125+
* rules, and the system-requirement check. So every service and every
126+
* definition a plugin registers in `init()` or `start()` is present, a
127+
* driver it connects there included, and the kernel is `running`.
128+
* `shutdown()` is unchanged: `kernel:shutdown` is dispatched, and every
129+
* plugin's `destroy()` and every `onShutdown()` handler runs. The kernel
130+
* itself waits on nothing a withheld hook would have done.
131+
*
132+
* **What it does NOT guarantee:** that the definitions equal a full boot's.
133+
* A plugin may register a definition in a boot-phase handler, and that
134+
* definition is absent here. That no handler does so on a given set of
135+
* plugins is a reading of that set, not a property of this option —
136+
* measure it for the plugins you compose. A `destroy()` or
137+
* `kernel:shutdown` handler that assumes its boot-phase handler ran must
138+
* tolerate that it did not. And it withholds the KERNEL's dispatch, not
139+
* the names: a plugin that calls `PluginContext.trigger` with one of them
140+
* itself still runs that hook's handlers.
141+
*/
142+
bootPhaseHooks?: boolean;
88143
}
89144

90145
/**
@@ -507,6 +562,17 @@ export class ObjectKernel {
507562

508563
// Phase 3: Trigger kernel:ready hook
509564
this.validateSystemRequirements(); // Final check before ready
565+
566+
// Phases 3 to 4 are the boot-phase hooks, and nothing follows them
567+
// but the completion line, so a host that asked for them withheld
568+
// stops here. What that does and does not guarantee is stated on
569+
// `ObjectKernelConfig.bootPhaseHooks`.
570+
if (this.config.bootPhaseHooks === false) {
571+
this.reportWithheldBootPhaseHooks();
572+
this.logger.info('✅ Bootstrap complete (boot-phase hooks withheld: bootPhaseHooks is false)');
573+
return;
574+
}
575+
510576
this.logger.debug('Triggering kernel:ready hook');
511577
await this.context.trigger('kernel:ready');
512578

@@ -542,6 +608,29 @@ export class ObjectKernel {
542608
}
543609
}
544610

611+
/**
612+
* The one line a `bootPhaseHooks: false` boot owes: which boot-phase hooks
613+
* this kernel did not dispatch, and how many registered handlers each one
614+
* left uncalled. A boot that skips work silently reads as a full boot to
615+
* whoever reads its log, so it says so once, here.
616+
*
617+
* `warn`, not `error`: what is missing is functional (handlers not run),
618+
* visible in this very line, and asked for by the host.
619+
*/
620+
private reportWithheldBootPhaseHooks(): void {
621+
const withheldHandlers: Record<string, number> = {};
622+
for (const hook of BOOT_PHASE_HOOKS) {
623+
withheldHandlers[hook] = this.hooks.get(hook)?.length ?? 0;
624+
}
625+
const counts = BOOT_PHASE_HOOKS.map((hook) => `${hook}: ${withheldHandlers[hook]}`).join(', ');
626+
this.logger.warn(
627+
`Boot-phase hooks withheld (bootPhaseHooks is false); registered handlers not called: ${counts}. `
628+
+ 'Nothing a plugin does in those hooks ran on this kernel, so a definition registered there is absent '
629+
+ 'and a server that listens on kernel:listening is not listening.',
630+
{ withheldHandlers },
631+
);
632+
}
633+
545634
/**
546635
* Graceful shutdown with timeout
547636
*/

‎skills/objectstack-query/SKILL.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,11 @@ const [row] = await engine.find('project', { where: { name }, limit: 1, context:
6363
```
6464

6565
Pass any SUBSET of the execution envelope (identity, tenant, transaction):
66-
`{ isSystem: true }` for a system read, `{ flowRunId }` for provenance alone. On
67-
the READ methods it may sit in the query bag (above) OR in the trailing options
68-
argument, `engine.find(obj, query, { context })`; the trailing one wins when
69-
both are given. Writes take only the trailing argument.
66+
`{ isSystem: true }` for a system read, otherwise the caller's own context
67+
(user, position or permission set), or `403 PERMISSION_DENIED` (ADR-0096 D5).
68+
On the READ methods it may sit in the query bag (above) OR in the trailing
69+
options argument, `engine.find(obj, query, { context })`; the trailing one wins
70+
when both are given. Writes take only the trailing argument.
7071

7172
## Removed Keys → Live Replacement
7273

0 commit comments

Comments
 (0)