Skip to content

Commit f6e1d49

Browse files
feat(core): ObjectKernelConfig.bootPhaseHooks, a declared option to bootstrap without dispatching kernel:ready / kernel:bootstrapped / kernel:listening (#22354)
Fixes #22272 Clause-②: yes (widening: a new public option on `ObjectKernel`) That line is the claim's (comment `6067012449`), copied verbatim. ## What `ObjectKernelConfig.bootPhaseHooks?: boolean`, default `true`. `new ObjectKernel({ bootPhaseHooks: false })` runs the rest of `bootstrap()` unchanged: dependency ordering, every plugin's `init()`, the core-service fallbacks, every plugin's `start()` and the system-requirement check. It then returns without dispatching `kernel:ready`, `kernel:bootstrapped` or `kernel:listening`, and the kernel is `running`. Only an explicit `false` withholds the hooks. An absent option or `undefined` keeps today's boot. `Runtime` forwards the option through its existing `kernel` config with no change. A withholding boot logs one `warn`. It names each withheld hook and how many registered handlers it left uncalled, and the line's meta carries the same counts as `withheldHandlers`. The boot's completion line says the hooks were withheld. The option's docblock states three things: - **What it withholds:** the kernel's dispatch of the three hooks, and nothing else. - **What it guarantees:** every service and definition a plugin registers in `init()` or `start()`, including a driver connected there. `shutdown()` is unchanged. - **What it does not guarantee:** that the definitions equal a full boot's. A definition registered in a boot-phase handler is absent. "No handler registers one" is a reading of a given set of plugins, not a property of the option. It withholds the kernel's dispatch, not the hook names. ## The PM's mechanism hypotheses, as measured - **H1 holds.** In `ObjectKernel.bootstrap()` (`packages/core/src/kernel.ts` at `3599fef12`), the order is: - the Phase 1 init loop (L442-445); - `preInjectCoreFallbacks()` (L451); - the Phase 2 start loop (L453-484), with `state = 'running'` at L455; - `validateSystemRequirements()` (L487); - `context.trigger('kernel:ready')` (L489), `'kernel:bootstrapped'` (L501) and `'kernel:listening'` (L511); - the completion line (L513). No hook fires between `init` and `start`, and only the completion line follows the three. `git grep` over the non-test files in `packages/**` finds no other caller that triggers any of the three names. `hook-dispatch.ts` holds only the shared dispatch loop, so the option is one branch after `validateSystemRequirements()`. - **H2: the option gives the same observable result as the consumer's wrapper, measured with in-repo plugins.** A one-off script (not committed) composed `ObjectQLPlugin`, `DefaultDatasourcePlugin` (sqlite-wasm, in memory), `AppPlugin(examples/app-todo)` and a probe plugin that subscribes to all three hooks. It booted that composition three ways: a full boot, cloud's patch reproduced (wrap `context.hook` and drop the three names), and `bootPhaseHooks: false`. Results: - object definitions from `objectql.registry.getAllObjects()`: 5 in each mode, with the JSON byte-equal across all three (sha256 prefix `e5a58708de9898a4`); - datasource definitions: `["default"]` in all three; - the probe fired `kernel:ready`, `kernel:bootstrapped` and `kernel:listening` on the full boot, and nothing in the other two modes; - every kernel shut down to `stopped`. That a full boot matches the withheld boot is again a reading of one slate. The two mechanisms differ in one place: the wrapper refuses the registration, while the option skips the dispatch. So under the option a handler for those names is still stored, and counted in the `warn`. A plugin that called `context.trigger` on one of the names itself would still run its handlers, but no plugin in this repository does that. - **H3: the name is `bootPhaseHooks?: boolean`, default `true`.** It names the mechanism the kernel controls, and it is the card's own spelling: `{ bootPhaseHooks: false }` reads as "no boot-phase hooks". Among the existing options it sits with the positive-sense booleans that default on, `gracefulShutdown` and `rollbackOnFailure`. It does not follow `skipSystemValidation`, because that option's `skip*` spelling and docblock mark a test escape, and this is a production mode for a repair host. A mode named for the outcome, such as `definitionsOnly` or `bootMode: 'repair'`, was rejected. It would promise complete definitions, and the kernel cannot keep that promise, because a definition registered in a boot-phase handler is absent. ## The card's open question (the seat's ruling in the claim) This is a separate `ObjectKernel` option, not a variant of `composeForDeclarations`, and the cli utility is not touched. The cli utility cannot share this mechanism as it is written. `composeForDeclarations` works per host plugin. A context proxy declines `kernel:bootstrapped` and `kernel:listening` at registration, and that plugin's `start()` is suppressed. The base stack's hooks, `kernel:ready` included, still run on that boot. The option is kernel-wide and keeps every `start()`. Moving the cli onto it would change which plugins' hooks a migration boot runs. ## Tests `packages/core/src/kernel.boot-phase-hooks.test.ts` holds 9 tests over one composition. A registry plugin and a declarer register one definition each in `init()`, in `start()` and in a `kernel:ready` handler, with a spy on each boot-phase hook. - **Under `bootPhaseHooks: false`:** - the `init()` and `start()` definitions are present and the kernel runs; - the `kernel:ready` definition is absent; - no spy fires; - a `kernel:ready` handler that throws and a `kernel:bootstrapped` handler that never settles do not stop the boot; - the `warn` reports `{ kernel:ready: 2, kernel:bootstrapped: 1, kernel:listening: 1 }`, once; - `shutdown()` runs `kernel:shutdown`, `destroy()` and `onShutdown()` in that order, and reaches `stopped` under a 2 s guard without calling `process.exit`. No withheld hook fires during shutdown. - **Controls** (the option absent, `true` and `undefined`): all three hooks fire once, in order, all three definitions are present (the `kernel:ready` one included), and nothing is reported as withheld. **Ablation.** The option was made a no-op through `scripts/ablation-replace.mjs`: the anchor `if (this.config.bootPhaseHooks === false) {` was replaced with `if (this.config.bootPhaseHooks === false && false) {`, the anchor count went from 1 to 0, and the blob changed. The result was 5 red and 4 green, in the expected direction. - Red: the absent definition, no dispatch, the throw and never-settle case, the withheld report, and shutdown (the spies fired). - Green: the `init()`/`start()` guarantee pin and the three controls. The run was repeated at the merged head `4e62f58ea` (blob `707ede024adb` to `5ed4fdcf902c`) with the same 5/4 split. After restore, the blob equals HEAD's `707ede024adb` and `git diff HEAD` is empty. The test imports `./kernel` from source, so `dist/` is not in the resolution path. **Runs at `4e62f58ea`** (the branch with `origin/main` `54c3ce10c` merged in, which brings in #22334's signal-listener change to the same file; the textual merge was clean): - `pnpm --filter @objectstack/core test`: 82 files, 2228 tests passed. Before the merge, at `63173d1c6`: 80 files, 2208 tests. - `pnpm --filter @objectstack/core typecheck`: green. `check:test-typecheck` is OK: the new file is in the `tsconfig.test.json` program and compiles clean, and the ledger is unchanged. - A reverse check of the rebuilt `.d.ts`, run from `@objectstack/runtime` at `63173d1c6` with a throwaway file that was then deleted: - `{ bootPhaseHooks: false }` compiles when typed as `ObjectKernelConfig` and as `RuntimeConfig.kernel`; - `{ bootPhaseHooks: 'no' }` fails with TS2322; - runtime's program reports no other error. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` was derived at `4e62f58ea` with no paths passed. The change set is 3 paths against merge base `54c3ce10c`, and the derivation gives 63 commands, the dispatch list's 49 plus 14. Most of the 14 come from the changeset, which now exists. The rest come from the new test file: `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:where-matcher`, `check:type-check-coverage` and `check:type-check-debt`. - All 63 were run at `4e62f58ea`, and all 63 exited 0. Exit codes were captured before any pipe. - The reconciliation over an exit-coded record printed: `✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3).` - Some readings: - `check:kernel-hook-pairs`: 4 dispatched `kernel:*` hooks, each pinned in both `kernel.test.ts` and `lite-kernel.test.ts`; - `check:nul-bytes`: OK; - `check:type-check-debt`: 26 raw errors, none above their recorded number; - `check:dual-build-cjs-loads`: 106 require entry points across 66 packages load. An earlier run at `5eb197934` printed PREREQUISITE NOT MET, which is not a pass. It measured on a later tree. - Left to CI, as the derivation itself lists: the path-scheduled CI jobs, the four type-check lanes, the 52 artifact-roster families and the 11 wide-population families. ## Signal listeners under `bootPhaseHooks: false` (the `domain:engine` seat's question `6067560824`) Written into this body by the `domain:spec` seat 2 at 2026-10-08T21:45Z. The comments before it on this PR are read: the docs-drift bot's, and the contract review **PASS** `6069627157`. - **The two options are orthogonal, by intent.** - `ObjectKernel` installs its SIGINT / SIGTERM / SIGQUIT listeners in the constructor when `gracefulShutdown` is true, the default (`kernel.ts` about `:301`). It releases them at every transition into `stopped` (#22334). - `bootPhaseHooks: false` changes neither. A kernel booted with it is `running` and holds the listeners until its `shutdown()`. - **A host that owns the process** passes `gracefulShutdown: false` as well, as this PR's test fixture does (`kernel.boot-phase-hooks.test.ts` about `:41`). A repair kernel inside such a host is that shape. - What `gracefulShutdown: false` means for process ownership is #22335's decision, not this PR's. This PR adds nothing to it and takes nothing from it. - **Review of the `packages/core` hunks by the `domain:engine` seat**, as it stated in `6067560824`: requested when this PR leaves draft. Auto-merge is enabled only after that review. ## Acceptance notes - **`LiteKernel` has no matching option.** The claim scopes this card to `ObjectKernel`, the kernel a hosted runtime boots. No hook name is added, so `check:kernel-hook-pairs` stays green. Carrier: none. - **Docs.** The "Boot Configuration" table in `content/docs/protocol/kernel/lifecycle.mdx` lists the kernel options and could gain a row for this one. That file is outside this claim's file surface, and the table makes no claim to be complete, so nothing in it is false now. Carrier: none. - **A cli comment.** A comment in `packages/cli/src/utils/schema-migrate.ts` says `kernel.ts` fires `kernel:ready` "unconditionally". That is still true for that boot, which does not set the option. Noted, not changed. - **Shutdown.** The kernel waits on nothing a withheld hook would have done. A plugin's own `destroy()` that assumes its boot-phase handler ran is that plugin's concern, and the docblock says so. The in-repo HTTP adapter's `close()` already returns early when it never listened (`packages/plugins/plugin-hono-server/src/adapter.ts`). - **Cross-lane.** `packages/core` belongs to the `domain:engine` lane, declared on #6367 (comment `6067025526`). A contract review at `CONTRACT_REVIEW_TIER` is owed before enqueue. That review belongs to the seat. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c8c803c commit f6e1d49

3 files changed

Lines changed: 342 additions & 0 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
*/

0 commit comments

Comments
 (0)