Skip to content

Commit aee1496

Browse files
committed
feat(kernel): add kernel:bootstrapped lifecycle anchor
kernel:ready handlers run sequentially in plugin-registration order, so a handler that consumes data produced by a later-starting plugin (security bootstrap seeds sys_position; the app plugin's seed loader inserts records) races the very rows it needs. Add kernel:bootstrapped — fired after every kernel:ready handler has settled but before kernel:listening (HTTP socket open) — as the correct anchor for reconcile/backfill work. Both ObjectKernel and LiteKernel trigger it; ordering is locked by tests in kernel.test.ts and lite-kernel.test.ts. The sharing-rule boot backfill moves from kernel:listening to kernel:bootstrapped (semantics-only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
1 parent e07645c commit aee1496

12 files changed

Lines changed: 94 additions & 10 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/plugin-sharing': patch
5+
---
6+
7+
feat(kernel): add `kernel:bootstrapped` lifecycle anchor — the phase that fires after every `kernel:ready` handler has settled but before `kernel:listening` (HTTP socket open). `kernel:ready` handlers run sequentially in plugin-registration order, so a handler that consumes data produced by a later-starting plugin (e.g. the security bootstrap seeds `sys_position`; the app plugin's seed loader inserts records) would race the very rows it needs. `kernel:bootstrapped` is the correct anchor for reconcile/backfill work: every producer's ready handler has finished by the time it fires. Both `ObjectKernel` and `LiteKernel` trigger it. The sharing-rule boot backfill moves from `kernel:listening` to `kernel:bootstrapped` (semantics-only; behaviour unchanged).

content/docs/kernel/events.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ Triggered by the Kernel during bootstrap and shutdown:
1919
| Event | Description |
2020
| :--- | :--- |
2121
| `kernel:ready` | All plugins have successfully started. System is live. |
22-
| `kernel:listening` | Fired after every `kernel:ready` handler has completed (e.g. the HTTP server is accepting connections). |
22+
| `kernel:bootstrapped` | Fired after every `kernel:ready` handler has settled, before `kernel:listening`. The "all bootstrap + seed data is ready" anchor — use it for reconcile/backfill work that consumes data a later-starting plugin produces during `kernel:ready`. |
23+
| `kernel:listening` | Fired after every `kernel:ready` and `kernel:bootstrapped` handler has completed (e.g. the HTTP server is accepting connections). |
2324
| `kernel:shutdown` | Shutdown signal received. Plugins should clean up resources. |
2425

2526
### Listening to Kernel Events

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ Plugin lifecycle event type
142142
### Allowed Values
143143

144144
* `kernel:ready`
145+
* `kernel:bootstrapped`
145146
* `kernel:listening`
146147
* `kernel:shutdown`
147148
* `kernel:before-init`

packages/core/src/kernel.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,24 @@ describe('ObjectKernel', () => {
417417
expect(kernel.getState()).toBe('stopped');
418418
});
419419

420+
it('fires kernel:ready → kernel:bootstrapped → kernel:listening in order', async () => {
421+
const order: string[] = [];
422+
const plugin: Plugin = {
423+
name: 'lifecycle-order-plugin',
424+
version: '1.0.0',
425+
init: async (ctx) => {
426+
ctx.hook('kernel:listening', async () => { order.push('kernel:listening'); });
427+
ctx.hook('kernel:bootstrapped', async () => { order.push('kernel:bootstrapped'); });
428+
ctx.hook('kernel:ready', async () => { order.push('kernel:ready'); });
429+
},
430+
};
431+
432+
await kernel.use(plugin);
433+
await kernel.bootstrap();
434+
435+
expect(order).toEqual(['kernel:ready', 'kernel:bootstrapped', 'kernel:listening']);
436+
});
437+
420438
it('should trigger shutdown hook', async () => {
421439
let hookCalled = false;
422440

packages/core/src/kernel.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,15 @@ export class ObjectKernel {
363363
this.logger.debug('Triggering kernel:ready hook');
364364
await this.context.trigger('kernel:ready');
365365

366+
// Phase 3.5: Trigger kernel:bootstrapped AFTER every kernel:ready
367+
// handler has settled — the "all bootstrap + seed data is ready"
368+
// anchor. Reconcile/backfill work that consumes data produced by a
369+
// later-starting plugin's kernel:ready handler belongs here, not in
370+
// kernel:ready (where handler order would race the data). See
371+
// packages/spec/src/contracts/plugin-lifecycle-events.ts.
372+
this.logger.debug('Triggering kernel:bootstrapped hook');
373+
await this.context.trigger('kernel:bootstrapped');
374+
366375
// Phase 4: Trigger kernel:listening hook AFTER all kernel:ready
367376
// handlers have completed. This is the cue for HTTP server
368377
// plugins to actually open the listening socket — by now every

packages/core/src/lite-kernel.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,4 +251,24 @@ describe('LiteKernel with Configurable Logger', () => {
251251
await kernel.shutdown();
252252
});
253253
});
254+
255+
describe('Lifecycle event ordering', () => {
256+
it('fires kernel:ready → kernel:bootstrapped → kernel:listening in order', async () => {
257+
const order: string[] = [];
258+
const plugin: Plugin = {
259+
name: 'lifecycle-order-plugin',
260+
init: async (ctx) => {
261+
ctx.hook('kernel:listening', async () => { order.push('kernel:listening'); });
262+
ctx.hook('kernel:bootstrapped', async () => { order.push('kernel:bootstrapped'); });
263+
ctx.hook('kernel:ready', async () => { order.push('kernel:ready'); });
264+
},
265+
};
266+
267+
kernel.use(plugin);
268+
await kernel.bootstrap();
269+
await kernel.shutdown();
270+
271+
expect(order).toEqual(['kernel:ready', 'kernel:bootstrapped', 'kernel:listening']);
272+
});
273+
});
254274
});

packages/core/src/lite-kernel.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ export class LiteKernel extends ObjectKernelBase {
7777

7878
// Trigger ready hook (route/middleware registration phase)
7979
await this.triggerHook('kernel:ready');
80+
// Trigger bootstrapped hook — "all bootstrap + seed data is ready"
81+
// anchor, strictly after every kernel:ready handler has settled and
82+
// before any HTTP socket opens (see plugin-lifecycle-events.ts).
83+
await this.triggerHook('kernel:bootstrapped');
8084
// Trigger listening hook (HTTP servers open their socket here —
8185
// strictly after every kernel:ready handler has completed).
8286
await this.triggerHook('kernel:listening');

packages/plugins/plugin-sharing/src/sharing-plugin.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,10 @@ export class SharingServicePlugin implements Plugin {
308308
bindRuleProvenanceStamp(engine, ctx.logger as any);
309309

310310
// [#2926 ③] Reconciling existing rows against every rule is
311-
// deferred to `kernel:listening` (below): seed data is loaded on
311+
// deferred to `kernel:bootstrapped` (below): seed data is loaded on
312312
// `kernel:ready` (raced against a budget, and the AppPlugin's seed
313313
// hook is a *different* kernel:ready handler), so a backfill here
314-
// would race the very records it must materialize. `kernel:listening`
314+
// would race the very records it must materialize. `kernel:bootstrapped`
315315
// fires only after every kernel:ready handler has settled.
316316
} else {
317317
ctx.logger.warn('SharingServicePlugin: engine has no hook API — sharing rule auto-evaluation disabled');
@@ -397,17 +397,17 @@ export class SharingServicePlugin implements Plugin {
397397
// [#2926 ③] Materialize sharing grants for rows already present at boot —
398398
// notably SeedLoader-inserted seed records, whose write goes through the
399399
// isSystem short-circuit in the rule hooks and therefore never produces a
400-
// `sys_record_share`. Runs on `kernel:listening` (Phase 4), after every
401-
// `kernel:ready` handler including the AppPlugin seed loader — has
402-
// completed, so the reconcile sees the seeded rows. Idempotent: a runtime
400+
// `sys_record_share`. Runs on `kernel:bootstrapped` — the anchor that fires
401+
// after every `kernel:ready` handler (including the AppPlugin seed loader)
402+
// has settled — so the reconcile sees the seeded rows. Idempotent: a runtime
403403
// write that already materialized a grant is reconciled to the same state.
404-
ctx.hook('kernel:listening', async () => {
404+
ctx.hook('kernel:bootstrapped', async () => {
405405
if (!this.ruleService) return;
406406
try {
407407
const rules = await this.ruleService.listRules({ activeOnly: true }, { isSystem: true } as any);
408408
await backfillRuleGrants(this.ruleService, rules, ctx.logger as any);
409409
} catch (err: any) {
410-
ctx.logger.warn('SharingServicePlugin: boot rule backfill (kernel:listening) failed', { error: err?.message });
410+
ctx.logger.warn('SharingServicePlugin: boot rule backfill (kernel:bootstrapped) failed', { error: err?.message });
411411
}
412412
});
413413
}

packages/spec/src/contracts/plugin-lifecycle-events.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ describe('Plugin Lifecycle Events Contract', () => {
66
it('should define all kernel event types', () => {
77
// Compile-time check: verify the event map type is correctly shaped
88
const events: Record<keyof Pick<IPluginLifecycleEvents,
9-
'kernel:ready' | 'kernel:shutdown' | 'kernel:before-init' | 'kernel:after-init'
9+
'kernel:ready' | 'kernel:bootstrapped' | 'kernel:listening' | 'kernel:shutdown' | 'kernel:before-init' | 'kernel:after-init'
1010
>, any> = {
1111
'kernel:ready': [],
12+
'kernel:bootstrapped': [],
13+
'kernel:listening': [],
1214
'kernel:shutdown': [],
1315
'kernel:before-init': [],
1416
'kernel:after-init': [150],

packages/spec/src/contracts/plugin-lifecycle-events.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,26 @@ export interface IPluginLifecycleEvents {
2020
'kernel:ready': [];
2121

2222
/**
23-
* Emitted AFTER all `kernel:ready` handlers have completed.
23+
* Emitted AFTER every `kernel:ready` handler has completed, but BEFORE
24+
* `kernel:listening` (so before any HTTP socket opens).
25+
*
26+
* This is the "all bootstrap + seed data has settled" anchor. Because
27+
* `kernel:ready` handlers run sequentially in plugin-registration order,
28+
* a handler cannot rely on data produced by a plugin that starts later
29+
* (e.g. the security bootstrap seeds `sys_position`, the app plugin's
30+
* seed loader inserts records) — reconcile/backfill work that consumes
31+
* that data would race the very rows it needs. Do such work here instead:
32+
* every producer's `kernel:ready` handler has finished by the time this
33+
* fires. HTTP `listen()` is deliberately deferred one more phase to
34+
* `kernel:listening` so late route registration still lands.
35+
*
36+
* Payload: []
37+
*/
38+
'kernel:bootstrapped': [];
39+
40+
/**
41+
* Emitted AFTER all `kernel:ready` and `kernel:bootstrapped` handlers
42+
* have completed.
2443
*
2544
* Use this hook for actions that must happen *strictly after* every
2645
* other plugin has had a chance to register routes / services /

0 commit comments

Comments
 (0)