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
7 changes: 7 additions & 0 deletions .changeset/kernel-bootstrapped-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/spec': minor
'@objectstack/core': minor
'@objectstack/plugin-sharing': patch
---

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).
5 changes: 5 additions & 0 deletions .changeset/security-everyone-autobind-ordering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-security': patch
---

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.
3 changes: 2 additions & 1 deletion content/docs/kernel/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ Triggered by the Kernel during bootstrap and shutdown:
| Event | Description |
| :--- | :--- |
| `kernel:ready` | All plugins have successfully started. System is live. |
| `kernel:listening` | Fired after every `kernel:ready` handler has completed (e.g. the HTTP server is accepting connections). |
| `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`. |
| `kernel:listening` | Fired after every `kernel:ready` and `kernel:bootstrapped` handler has completed (e.g. the HTTP server is accepting connections). |
| `kernel:shutdown` | Shutdown signal received. Plugins should clean up resources. |

### Listening to Kernel Events
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/kernel/plugin-lifecycle-events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ Plugin lifecycle event type
### Allowed Values

* `kernel:ready`
* `kernel:bootstrapped`
* `kernel:listening`
* `kernel:shutdown`
* `kernel:before-init`
Expand Down
2 changes: 1 addition & 1 deletion examples/app-showcase/src/data/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ const orgUnits = SeedSchema.parse({
// [#2926 ②] Position ↔ permission-set bindings are NOT seeded here: the seed
// loader runs before the security bootstrap creates the sys_position /
// sys_permission_set rows, so the required name references cannot resolve.
// They are ensured imperatively on kernel:listening instead (after every
// They are ensured imperatively on kernel:bootstrapped instead (after every
// kernel:ready handler, incl. the security bootstrap, has settled) — see
// `src/security/bind-position-sets.ts` (wired via `onEnable`).

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

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

// Bind on `kernel:listening` — the phase that fires only after every
// Bind on `kernel:bootstrapped` — the anchor that fires only after every
// `kernel:ready` handler (incl. the security bootstrap that seeds the
// position/set rows) has completed. Fall back to a deferred immediate run
// position/set rows) has settled. Fall back to a deferred immediate run
// if the host context somehow omits the hook registrar.
if (typeof ctx.hook === 'function') {
ctx.hook('kernel:listening', run);
ctx.hook('kernel:bootstrapped', run);
} else {
setTimeout(() => void run(), 0);
}
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,24 @@ describe('ObjectKernel', () => {
expect(kernel.getState()).toBe('stopped');
});

it('fires kernel:ready → kernel:bootstrapped → kernel:listening in order', async () => {
const order: string[] = [];
const plugin: Plugin = {
name: 'lifecycle-order-plugin',
version: '1.0.0',
init: async (ctx) => {
ctx.hook('kernel:listening', async () => { order.push('kernel:listening'); });
ctx.hook('kernel:bootstrapped', async () => { order.push('kernel:bootstrapped'); });
ctx.hook('kernel:ready', async () => { order.push('kernel:ready'); });
},
};

await kernel.use(plugin);
await kernel.bootstrap();

expect(order).toEqual(['kernel:ready', 'kernel:bootstrapped', 'kernel:listening']);
});

it('should trigger shutdown hook', async () => {
let hookCalled = false;

Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,15 @@ export class ObjectKernel {
this.logger.debug('Triggering kernel:ready hook');
await this.context.trigger('kernel:ready');

// Phase 3.5: Trigger kernel:bootstrapped AFTER every kernel:ready
// handler has settled — the "all bootstrap + seed data is ready"
// anchor. Reconcile/backfill work that consumes data produced by a
// later-starting plugin's kernel:ready handler belongs here, not in
// kernel:ready (where handler order would race the data). See
// packages/spec/src/contracts/plugin-lifecycle-events.ts.
this.logger.debug('Triggering kernel:bootstrapped hook');
await this.context.trigger('kernel:bootstrapped');

// Phase 4: Trigger kernel:listening hook AFTER all kernel:ready
// handlers have completed. This is the cue for HTTP server
// plugins to actually open the listening socket — by now every
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/lite-kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,4 +251,24 @@ describe('LiteKernel with Configurable Logger', () => {
await kernel.shutdown();
});
});

describe('Lifecycle event ordering', () => {
it('fires kernel:ready → kernel:bootstrapped → kernel:listening in order', async () => {
const order: string[] = [];
const plugin: Plugin = {
name: 'lifecycle-order-plugin',
init: async (ctx) => {
ctx.hook('kernel:listening', async () => { order.push('kernel:listening'); });
ctx.hook('kernel:bootstrapped', async () => { order.push('kernel:bootstrapped'); });
ctx.hook('kernel:ready', async () => { order.push('kernel:ready'); });
},
};

kernel.use(plugin);
await kernel.bootstrap();
await kernel.shutdown();

expect(order).toEqual(['kernel:ready', 'kernel:bootstrapped', 'kernel:listening']);
});
});
});
4 changes: 4 additions & 0 deletions packages/core/src/lite-kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ export class LiteKernel extends ObjectKernelBase {

// Trigger ready hook (route/middleware registration phase)
await this.triggerHook('kernel:ready');
// Trigger bootstrapped hook — "all bootstrap + seed data is ready"
// anchor, strictly after every kernel:ready handler has settled and
// before any HTTP socket opens (see plugin-lifecycle-events.ts).
await this.triggerHook('kernel:bootstrapped');
// Trigger listening hook (HTTP servers open their socket here —
// strictly after every kernel:ready handler has completed).
await this.triggerHook('kernel:listening');
Expand Down
82 changes: 44 additions & 38 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1362,44 +1362,9 @@ export class SecurityPlugin implements Plugin {
ctx.logger.warn('[security] declared-permission seeding failed', { error: (e as Error).message });
}

// [ADR-0090 D5] Bind the configured baseline set to the `everyone`
// audience anchor (idempotent). This makes the CLI/dev fallback
// (`fallbackPermissionSet` — the app's `isDefault` suggestion) visible
// as an ordinary position binding: same table, same audit path, same
// explain surface as any admin-authored default grant. The binding is
// validated with the SAME high-privilege predicate the write gate
// enforces — a dangerous baseline is refused loudly, never seeded.
try {
if (this.fallbackPermissionSet) {
const boot = this.bootstrapPermissionSets.find((p) => p.name === this.fallbackPermissionSet);
const offending = boot ? describeHighPrivilegeBits(boot) : null;
if (offending) {
ctx.logger.warn('[security] refusing to bind fallback set to everyone — high-privilege bits', {
set: this.fallbackPermissionSet, offending,
});
} else {
const everyoneRows = await ql.find('sys_position', { where: { name: 'everyone' }, limit: 1, context: { isSystem: true } });
const everyone: any = Array.isArray(everyoneRows) && everyoneRows[0] ? everyoneRows[0] : null;
const setRows = await ql.find('sys_permission_set', { where: { name: this.fallbackPermissionSet }, limit: 1, context: { isSystem: true } });
const set: any = Array.isArray(setRows) && setRows[0] ? setRows[0] : null;
if (everyone?.id && set?.id) {
const existing = await ql.find('sys_position_permission_set', {
where: { position_id: everyone.id, permission_set_id: set.id }, limit: 1, context: { isSystem: true },
});
if (!(Array.isArray(existing) && existing[0])) {
await ql.insert('sys_position_permission_set', {
id: `pps_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
position_id: everyone.id,
permission_set_id: set.id,
}, { context: { isSystem: true } });
ctx.logger.info('[security] baseline set bound to everyone anchor (ADR-0090 D5)', { set: this.fallbackPermissionSet });
}
}
}
}
} catch (e) {
ctx.logger.warn('[security] everyone-anchor baseline binding failed (non-fatal)', { error: (e as Error).message });
}
// [ADR-0090 D5] The baseline→`everyone` binding runs LATER — after
// `bootstrapBuiltinRoles` seeds the `everyone` anchor (the anchor must
// exist before it can be bound). See `bindFallbackToEveryone` below.
// [ADR-0086 P2 — 块1] Register the publish-time materializer so a
// permission set authored/edited through the PACKAGE door (saved as a
// `permission` draft, then published) lands in sys_permission_set with
Expand Down Expand Up @@ -1493,6 +1458,47 @@ export class SecurityPlugin implements Plugin {
} catch (e) {
ctx.logger.warn('[security] built-in role seeding failed', { error: (e as Error).message });
}
// [ADR-0090 D5] Bind the configured baseline set to the `everyone`
// audience anchor (idempotent). This makes the CLI/dev fallback
// (`fallbackPermissionSet` — the app's `isDefault` set) visible as an
// ordinary position binding: same table, same audit path, same explain
// surface as any admin-authored default grant. The binding is validated
// with the SAME high-privilege predicate the write gate enforces — a
// dangerous baseline is refused loudly, never seeded. MUST run after
// `bootstrapBuiltinRoles` (which seeds the `everyone` anchor) and before
// `syncAudienceBindingSuggestions` (so the app's own fallback set is
// already bound and never generates a redundant pending suggestion).
try {
if (this.fallbackPermissionSet) {
const boot = this.bootstrapPermissionSets.find((p) => p.name === this.fallbackPermissionSet);
const offending = boot ? describeHighPrivilegeBits(boot) : null;
if (offending) {
ctx.logger.warn('[security] refusing to bind fallback set to everyone — high-privilege bits', {
set: this.fallbackPermissionSet, offending,
});
} else {
const everyoneRows = await ql.find('sys_position', { where: { name: 'everyone' }, limit: 1, context: { isSystem: true } });
const everyone: any = Array.isArray(everyoneRows) && everyoneRows[0] ? everyoneRows[0] : null;
const setRows = await ql.find('sys_permission_set', { where: { name: this.fallbackPermissionSet }, limit: 1, context: { isSystem: true } });
const set: any = Array.isArray(setRows) && setRows[0] ? setRows[0] : null;
if (everyone?.id && set?.id) {
const existing = await ql.find('sys_position_permission_set', {
where: { position_id: everyone.id, permission_set_id: set.id }, limit: 1, context: { isSystem: true },
});
if (!(Array.isArray(existing) && existing[0])) {
await ql.insert('sys_position_permission_set', {
id: `pps_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
position_id: everyone.id,
permission_set_id: set.id,
}, { context: { isSystem: true } });
ctx.logger.info('[security] baseline set bound to everyone anchor (ADR-0090 D5)', { set: this.fallbackPermissionSet });
}
}
}
}
} catch (e) {
ctx.logger.warn('[security] everyone-anchor baseline binding failed (non-fatal)', { error: (e as Error).message });
}
// [ADR-0090 D5/D9] Reconcile the suggested-audience-binding surface:
// every declared `isDefault: true` set that is not already bound to
// its anchor becomes a PENDING suggestion row awaiting admin
Expand Down
14 changes: 7 additions & 7 deletions packages/plugins/plugin-sharing/src/sharing-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,10 @@ export class SharingServicePlugin implements Plugin {
bindRuleProvenanceStamp(engine, ctx.logger as any);

// [#2926 ③] Reconciling existing rows against every rule is
// deferred to `kernel:listening` (below): seed data is loaded on
// deferred to `kernel:bootstrapped` (below): seed data is loaded on
// `kernel:ready` (raced against a budget, and the AppPlugin's seed
// hook is a *different* kernel:ready handler), so a backfill here
// would race the very records it must materialize. `kernel:listening`
// would race the very records it must materialize. `kernel:bootstrapped`
// fires only after every kernel:ready handler has settled.
} else {
ctx.logger.warn('SharingServicePlugin: engine has no hook API — sharing rule auto-evaluation disabled');
Expand Down Expand Up @@ -397,17 +397,17 @@ export class SharingServicePlugin implements Plugin {
// [#2926 ③] Materialize sharing grants for rows already present at boot —
// notably SeedLoader-inserted seed records, whose write goes through the
// isSystem short-circuit in the rule hooks and therefore never produces a
// `sys_record_share`. Runs on `kernel:listening` (Phase 4), after every
// `kernel:ready` handler including the AppPlugin seed loader — has
// completed, so the reconcile sees the seeded rows. Idempotent: a runtime
// `sys_record_share`. Runs on `kernel:bootstrapped` — the anchor that fires
// after every `kernel:ready` handler (including the AppPlugin seed loader)
// has settled — so the reconcile sees the seeded rows. Idempotent: a runtime
// write that already materialized a grant is reconciled to the same state.
ctx.hook('kernel:listening', async () => {
ctx.hook('kernel:bootstrapped', async () => {
if (!this.ruleService) return;
try {
const rules = await this.ruleService.listRules({ activeOnly: true }, { isSystem: true } as any);
await backfillRuleGrants(this.ruleService, rules, ctx.logger as any);
} catch (err: any) {
ctx.logger.warn('SharingServicePlugin: boot rule backfill (kernel:listening) failed', { error: err?.message });
ctx.logger.warn('SharingServicePlugin: boot rule backfill (kernel:bootstrapped) failed', { error: err?.message });
}
});
}
Expand Down
4 changes: 3 additions & 1 deletion packages/spec/src/contracts/plugin-lifecycle-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ describe('Plugin Lifecycle Events Contract', () => {
it('should define all kernel event types', () => {
// Compile-time check: verify the event map type is correctly shaped
const events: Record<keyof Pick<IPluginLifecycleEvents,
'kernel:ready' | 'kernel:shutdown' | 'kernel:before-init' | 'kernel:after-init'
'kernel:ready' | 'kernel:bootstrapped' | 'kernel:listening' | 'kernel:shutdown' | 'kernel:before-init' | 'kernel:after-init'
>, any> = {
'kernel:ready': [],
'kernel:bootstrapped': [],
'kernel:listening': [],
'kernel:shutdown': [],
'kernel:before-init': [],
'kernel:after-init': [150],
Expand Down
Loading