Skip to content

Commit 464418e

Browse files
os-zhuangclaude
andauthored
feat(kernel): kernel:bootstrapped anchor + fix everyone auto-bind ordering (#2926 follow-ups) (#2989)
* 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 * fix(plugin-security): bind fallback set to everyone AFTER the anchor is seeded The ADR-0090 D5 baseline auto-bind ran earlier in runBootstrap than bootstrapBuiltinRoles, which creates the everyone position — so the everyone lookup returned nothing and the app's isDefault set (resolved as fallbackPermissionSet) was never bound. A fresh deploy booted with everyone empty (personas silently degraded) plus a redundant sys_audience_binding_suggestion for the same set. Move the auto-bind after bootstrapBuiltinRoles and before syncAudienceBindingSuggestions so the documented app-level auto-bind actually happens and the suggestion sync skips the already-bound set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe * chore(app-showcase): bind persona sets on kernel:bootstrapped; drop redundant everyone binding The security plugin now auto-binds the app's isDefault set (showcase_member_default) to everyone at boot, so the app glue no longer needs to. Keep only the persona → set bindings the framework cannot infer, and move them from kernel:listening to the kernel:bootstrapped anchor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e07645c commit 464418e

16 files changed

Lines changed: 154 additions & 62 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).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): bind the fallback permission set to the `everyone` anchor AFTER the anchor is seeded. The baseline auto-bind (ADR-0090 D5) ran earlier in `runBootstrap` than `bootstrapBuiltinRoles`, which creates the `everyone` position — so the `everyone` lookup returned nothing and the app's `isDefault` set was never bound, leaving a fresh deploy's `everyone` empty (personas silently degraded) and a redundant `sys_audience_binding_suggestion` filed for the same set. The auto-bind now runs after `bootstrapBuiltinRoles` and before `syncAudienceBindingSuggestions`, so the documented app-level auto-bind actually happens and the suggestion sync correctly skips the already-bound set.

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`

examples/app-showcase/src/data/seed/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ const orgUnits = SeedSchema.parse({
165165
// [#2926 ②] Position ↔ permission-set bindings are NOT seeded here: the seed
166166
// loader runs before the security bootstrap creates the sys_position /
167167
// sys_permission_set rows, so the required name references cannot resolve.
168-
// They are ensured imperatively on kernel:listening instead (after every
168+
// They are ensured imperatively on kernel:bootstrapped instead (after every
169169
// kernel:ready handler, incl. the security bootstrap, has settled) — see
170170
// `src/security/bind-position-sets.ts` (wired via `onEnable`).
171171

examples/app-showcase/src/security/bind-position-sets.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,18 @@
1818
* registration order (`kernel.ts` `trigger`). The showcase AppPlugin starts
1919
* BEFORE the Security plugin, so an app hook on `kernel:ready` runs *before*
2020
* the security bootstrap has created the position/set rows — the rows never
21-
* appear from inside that hook. We therefore bind on **`kernel:listening`**,
22-
* the phase the kernel fires only AFTER every `kernel:ready` handler has
23-
* completed (`kernel.ts` Phase 4 / `lite-kernel.ts`), so the bootstrap rows are
21+
* appear from inside that hook. We therefore bind on **`kernel:bootstrapped`**,
22+
* the anchor the kernel fires only AFTER every `kernel:ready` handler has
23+
* settled (`kernel.ts` Phase 3.5 / `lite-kernel.ts`), so the bootstrap rows are
2424
* guaranteed present.
2525
*
26-
* `everyone → showcase_member_default` IS bound here. The security plugin only
27-
* auto-binds an app's `isDefault` set to `everyone` when that set is
28-
* application-owned; the showcase ships as a package, so its default lands in
29-
* `sys_audience_binding_suggestion` (pending admin confirmation) and is NOT
30-
* live until confirmed. Binding it here keeps the demo's baseline working out
31-
* of the box, idempotently and alongside the suggestion.
26+
* `everyone → showcase_member_default` is NOT bound here: the security plugin
27+
* auto-binds the app's `isDefault` set (resolved as its `fallbackPermissionSet`)
28+
* to `everyone` at boot. This list only carries the persona → set bindings the
29+
* framework cannot infer.
3230
*/
3331

3432
const BINDINGS: ReadonlyArray<readonly [position: string, permissionSet: string]> = [
35-
['everyone', 'showcase_member_default'],
3633
['contributor', 'showcase_contributor'],
3734
['manager', 'showcase_manager'],
3835
['exec', 'showcase_executive'],
@@ -106,12 +103,12 @@ export function registerShowcasePositionBindings(ctx: BindHostContext): void {
106103
ctx.logger?.info?.('[showcase] position bindings ensured', { created, total: BINDINGS.length });
107104
};
108105

109-
// Bind on `kernel:listening` — the phase that fires only after every
106+
// Bind on `kernel:bootstrapped` — the anchor that fires only after every
110107
// `kernel:ready` handler (incl. the security bootstrap that seeds the
111-
// position/set rows) has completed. Fall back to a deferred immediate run
108+
// position/set rows) has settled. Fall back to a deferred immediate run
112109
// if the host context somehow omits the hook registrar.
113110
if (typeof ctx.hook === 'function') {
114-
ctx.hook('kernel:listening', run);
111+
ctx.hook('kernel:bootstrapped', run);
115112
} else {
116113
setTimeout(() => void run(), 0);
117114
}

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');

0 commit comments

Comments
 (0)