diff --git a/.changeset/kernel-bootstrapped-anchor.md b/.changeset/kernel-bootstrapped-anchor.md new file mode 100644 index 0000000000..91e90294bf --- /dev/null +++ b/.changeset/kernel-bootstrapped-anchor.md @@ -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). diff --git a/.changeset/security-everyone-autobind-ordering.md b/.changeset/security-everyone-autobind-ordering.md new file mode 100644 index 0000000000..af1004c4e6 --- /dev/null +++ b/.changeset/security-everyone-autobind-ordering.md @@ -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. diff --git a/content/docs/kernel/events.mdx b/content/docs/kernel/events.mdx index d9844512c0..158e12db61 100644 --- a/content/docs/kernel/events.mdx +++ b/content/docs/kernel/events.mdx @@ -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 diff --git a/content/docs/references/kernel/plugin-lifecycle-events.mdx b/content/docs/references/kernel/plugin-lifecycle-events.mdx index 73495ffa53..1815d27880 100644 --- a/content/docs/references/kernel/plugin-lifecycle-events.mdx +++ b/content/docs/references/kernel/plugin-lifecycle-events.mdx @@ -142,6 +142,7 @@ Plugin lifecycle event type ### Allowed Values * `kernel:ready` +* `kernel:bootstrapped` * `kernel:listening` * `kernel:shutdown` * `kernel:before-init` diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 0d41c42a21..fd94211954 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -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`). diff --git a/examples/app-showcase/src/security/bind-position-sets.ts b/examples/app-showcase/src/security/bind-position-sets.ts index a41741108a..6188fda71e 100644 --- a/examples/app-showcase/src/security/bind-position-sets.ts +++ b/examples/app-showcase/src/security/bind-position-sets.ts @@ -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 = [ - ['everyone', 'showcase_member_default'], ['contributor', 'showcase_contributor'], ['manager', 'showcase_manager'], ['exec', 'showcase_executive'], @@ -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); } diff --git a/packages/core/src/kernel.test.ts b/packages/core/src/kernel.test.ts index c20a7b3812..2939201228 100644 --- a/packages/core/src/kernel.test.ts +++ b/packages/core/src/kernel.test.ts @@ -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; diff --git a/packages/core/src/kernel.ts b/packages/core/src/kernel.ts index f53fdb7d05..06badf17a1 100644 --- a/packages/core/src/kernel.ts +++ b/packages/core/src/kernel.ts @@ -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 diff --git a/packages/core/src/lite-kernel.test.ts b/packages/core/src/lite-kernel.test.ts index 4db4140a59..a27ba20bc5 100644 --- a/packages/core/src/lite-kernel.test.ts +++ b/packages/core/src/lite-kernel.test.ts @@ -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']); + }); + }); }); diff --git a/packages/core/src/lite-kernel.ts b/packages/core/src/lite-kernel.ts index ddd00db47b..e5667a0c62 100644 --- a/packages/core/src/lite-kernel.ts +++ b/packages/core/src/lite-kernel.ts @@ -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'); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index ba4a910ecd..cbaeb79113 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -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 @@ -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 diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 8cfc2acdce..8c77a3b9df 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -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'); @@ -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 }); } }); } diff --git a/packages/spec/src/contracts/plugin-lifecycle-events.test.ts b/packages/spec/src/contracts/plugin-lifecycle-events.test.ts index eef21a5959..e2a880b5e6 100644 --- a/packages/spec/src/contracts/plugin-lifecycle-events.test.ts +++ b/packages/spec/src/contracts/plugin-lifecycle-events.test.ts @@ -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, any> = { 'kernel:ready': [], + 'kernel:bootstrapped': [], + 'kernel:listening': [], 'kernel:shutdown': [], 'kernel:before-init': [], 'kernel:after-init': [150], diff --git a/packages/spec/src/contracts/plugin-lifecycle-events.ts b/packages/spec/src/contracts/plugin-lifecycle-events.ts index 5ef159f78a..11707625de 100644 --- a/packages/spec/src/contracts/plugin-lifecycle-events.ts +++ b/packages/spec/src/contracts/plugin-lifecycle-events.ts @@ -20,7 +20,26 @@ export interface IPluginLifecycleEvents { 'kernel:ready': []; /** - * Emitted AFTER all `kernel:ready` handlers have completed. + * Emitted AFTER every `kernel:ready` handler has completed, but BEFORE + * `kernel:listening` (so before any HTTP socket opens). + * + * This is the "all bootstrap + seed data has settled" anchor. Because + * `kernel:ready` handlers run sequentially in plugin-registration order, + * a handler cannot rely on data produced by a plugin that starts later + * (e.g. the security bootstrap seeds `sys_position`, the app plugin's + * seed loader inserts records) — reconcile/backfill work that consumes + * that data would race the very rows it needs. Do such work here instead: + * every producer's `kernel:ready` handler has finished by the time this + * fires. HTTP `listen()` is deliberately deferred one more phase to + * `kernel:listening` so late route registration still lands. + * + * Payload: [] + */ + 'kernel:bootstrapped': []; + + /** + * Emitted AFTER all `kernel:ready` and `kernel:bootstrapped` handlers + * have completed. * * Use this hook for actions that must happen *strictly after* every * other plugin has had a chance to register routes / services / diff --git a/packages/spec/src/kernel/plugin-lifecycle-events.test.ts b/packages/spec/src/kernel/plugin-lifecycle-events.test.ts index 0bc6b7ecfe..a4db596569 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-events.test.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-events.test.ts @@ -191,6 +191,8 @@ describe('Plugin Lifecycle Events Protocol', () => { it('should validate all event types', () => { const eventTypes = [ 'kernel:ready', + 'kernel:bootstrapped', + 'kernel:listening', 'kernel:shutdown', 'kernel:before-init', 'kernel:after-init', diff --git a/packages/spec/src/kernel/plugin-lifecycle-events.zod.ts b/packages/spec/src/kernel/plugin-lifecycle-events.zod.ts index 1f2d4e0662..883732c4cd 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-events.zod.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-events.zod.ts @@ -321,6 +321,7 @@ export type KernelShutdownEvent = z.infer; */ export const PluginLifecycleEventType = z.enum([ 'kernel:ready', + 'kernel:bootstrapped', 'kernel:listening', 'kernel:shutdown', 'kernel:before-init',