Skip to content

Commit 8a995b8

Browse files
fix(plugin-auth): register the app:seeded backfill handler from the arming kernel:ready handler (#22328) (#22333)
Fixes #22328 Clause-②: no The `plugin-auth` half of the parent card #22316. The gate half is PR #22325 (`scripts/check-settings-bind-window.mjs`), which lands after this one. This PR does not touch that script. ## What changed `packages/plugins/plugin-auth/src/auth-plugin.ts`: the ADR-0093 D6 backfill's `app:seeded` handler used to be registered from `start()`. Until the pass was armed, the `backfillArmed` runtime flag made it a no-op (PR #22312). It is now registered by the `kernel:ready` handler that arms the pass, in the same synchronous step: ```ts ctx.hook('kernel:ready', () => { backfillArmed = true; ctx.hook('app:seeded', () => runBackfill('app:seeded')); return runBackfill('kernel:ready'); }); ``` This is the seat's ruling on #22316 (comment 6065407005): "**Decision: (a).** `plugin-auth` registers the `app:seeded` handler from inside the arming `kernel:ready` handler, which is the flag's guarantee made structural." - **Producer side.** The registration lives only in `auth-plugin.ts`. No other package changes. - **`backfillArmed` stays.** It is not dead after the move. The `default-org-created` trigger (`runBackfillOnDefaultOrg`) still calls `runBackfill` before arming from two places. One is the `objectql` middleware, which a Phase-2 `sys_user` seed insert can trigger. The other is the bootstrap's own `kernel:ready` hook (`runEnsure`), which is registered earlier in `start()` and so runs before the arming handler. - **One unit test changed.** `auth-plugin.test.ts` had a test, "registers an app:seeded hook alongside kernel:ready", that checked the hook was registered right after `init()`+`start()`, which is the old structure. It now checks the new structure: no `app:seeded` handler after `start()`, and exactly one after `kernel:ready` fires. Neither #22312 pin changed. - **Changeset.** One patch changeset for `@objectstack/plugin-auth`. No option, setting, export or public type changes. ## Is behaviour identical? Measured, not assumed **(1) The hook bus accepts a registration made from inside another hook's handler, and a later emit reaches it.** `hook()` pushes onto the kernel's `hooks` map, creating the entry if needed. `trigger()` reads `this.hooks.get(name)` when it dispatches. Sources: `packages/core/src/kernel.ts` lines 198-209 (ObjectKernel), `kernel-base.ts` lines 118-128 (LiteKernel), and `security/plugin-permission-enforcer.ts` lines 453-456, which delegates. `dispatchHookPropagating` (`hook-dispatch.ts`) loops `for...of` over the live array, so a handler pushed during a dispatch is also picked up by that dispatch if it is still running. **(2) No `app:seeded` emit that the flag let through is lost.** The arming and the registration happen in one synchronous step, so no emit can fall between them. I measured both claims with a throwaway A/B probe, deleted afterwards and not committed. It booted real kernels (driver-sql in-memory SQLite, ObjectQLPlugin, the REAL SettingsServicePlugin) with the base `auth-plugin.ts` (`28bff18d0c`) and with this one. The one-time pass was stubbed `undecided`, so every `runBackfill` that actually ran counts as one visible call: | probe | base | this PR | |---|---|---| | P0 `app:seeded` handlers at a `start()`-time emit / after boot (ObjectKernel and LiteKernel) | 1 / 1 | **0** / 1 | | P1 a post-ready emit (the over-budget seed): passes after boot, then after the emit (both kernels) | 1, then 2 | 1, then 2 | | P2 dispatch in flight across arming, blocked by a subscriber BEFORE the auth slot | 2 | 2 | | P5 awaited pre-ready emit (the in-budget seed; the #22312 pin's shape) | 1 | 1 | | P3 dispatch in flight across arming, blocked by a subscriber AFTER the auth slot | 1 | **2** | | P4 order of a post-ready emit's subscribers, with another `start()`-registered subscriber whose plugin starts after auth | auth, other | **other, auth** | So assumption (2) holds, and so does (1) on both kernels. The probe also found two differences in scheduling, P3 and P4. Neither loses an emit. Both come from the same mechanism: a registration made after `start()` takes a later position in the subscriber list. - **P3.** A dispatch that started before arming has already passed the auth slot as a no-op on base. If it is still running when the pass arms, it now reaches the newly added handler, which queues one more post-bind `runBackfill('app:seeded')` behind the `kernel:ready` pass. That extra call stops at `backfillDecided` once the pass has decided, and otherwise runs the same idempotent pass any later trigger would. It runs after the bind, which is the guarantee this change is about. - **P4.** For an emit after arming, the auth handler now runs after any `app:seeded` subscriber registered in `start()` by a plugin that starts after auth. Before, it ran ahead of such subscribers. - **No in-repo instance of either on the shipped composition.** The `app:seeded` subscribers in this repo are `plugin-security` (registered in `init()`), the CLI seed-settlement announcer (`init()`), `platform-objects` (`start()`) and this one. `os serve` composes `PlatformObjectsPlugin` before `AuthPlugin` (`serve.ts` 3945 vs 4102), and no ordering edge reverses that. So the subscriber order on `os serve` is the same in both shapes, and no subscriber sits after the auth slot. Cloud-held compositions: NOT MEASURED. - **No structural shape is fully identical.** The kernel only appends; it has no API to insert a handler at a position. A `start()`-time registration is exactly what the gate rejects by design. The ruling's shape is the smallest structural one, and the residue is the position in the subscriber list. ## Proof the move does its job: PR #22325's gate (head `96d46ea02d`) over this tree I copied the gate into a scratch root. Its `scripts/` held symlinks to this worktree's scripts except for that one file, and `packages` and `node_modules` were symlinks to this worktree. The script's md5 matched `git show 96d46ea:scripts/check-settings-bind-window.mjs` (`dbd45f65ff759cded2ddbcf7e349d026`). It is not committed here. **Control, unmodified `origin/main` `28bff18d0c`:** exit 1 ```text ✗ settings bind-window guard (#11045) packages/plugins/plugin-auth/src/auth-plugin.ts:1591 — AuthPlugin getService('settings') runs in [app:seeded-hook-from-start], which is inside the pre-bind window under EVERY composition order: 'com.objectstack.service.settings' binds its engine from its own 'kernel:ready' hook, strictly after every plugin's init() and start(), after every handler registered during init(), and after every handler of a hook those phases fire. No dependency edge can move this read out of the window — do not add one and call it fixed. ... 1 settings read(s) in the pre-bind window with no declaration covering them. ``` **This tree, head `9c8ed2b62e`:** exit 0, no ledger entry ```text ✓ settings bind-window: 4 declared / 0 self / 1 structurally upstream / 0 ledgered (73 plugin unit(s) scanned, 8 pre-bind hook(s) fired = pinned, provider 'com.objectstack.service.settings'). ``` **Ablation**, with the fix committed at `9c8ed2b62e`. `scripts/ablation-replace.mjs` in WRAP mode moved the registration back out of the handler to its `start()`-time position. It reported "anchor 1 -> 0, blob 49113b9 -> 04decbf3fac2". On disk, the in-handler count went 1 to 0 and the start-time count 0 to 1. The gate read RED again, exit 1, at `auth-plugin.ts:1603 [app:seeded-hook-from-start]`. Restore: "blob == HEAD (49113b9) and `git diff HEAD` is empty". My own check agreed: `git hash-object` = `49113b925d9785632b45ea864d83f97d8a41b70f` = the `HEAD:` blob, and `git status` was clean. ## Tests (head `9c8ed2b62e`) - Built the dependency closure under the verify lock: `pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-auth^...' build`, VERDICT command-exit 0, 29 of 81 projects. Then `pnpm --filter @objectstack/plugin-auth build`. - `pnpm --filter @objectstack/plugin-auth typecheck`: VERDICT command-exit 0. `check:test-typecheck: OK — ... 10 file(s) / 94 error(s) / 23 pinned signature(s) held`, so no new test-layer error. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` (the full suite): VERDICT command-exit 0, `Test Files 129 passed (129)`, `Tests 2657 passed | 10 skipped (2667)`. - The #22312 pins, unchanged, run verbose: `auth-settings-seeded-boot.pin.test.ts` 2/2 (no Pre-bind READ; the control reports exactly 1), `auth-settings-ordering.pin.test.ts` 5/5. Also the `Membership backfill re-run on app:seeded (#2996)` block 11/11 and `membership-policy-setting.test.ts`. Together: 4 files, 123 tests passed. ## Gates (head `9c8ed2b62e`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands for 3 paths (48 changed lines). I ran each with its exit code captured before any pipe, plus `pnpm check:settings-bind-window` (this tree's own version: exit 0, "4 declared / 0 self / 1 structurally upstream / 0 ledgered"). `--ran` reports: "✓ dispatch-gates --ran: 67 derived famil(ies) accounted for — 66 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". - 66 of 67 exit 0, including `check:nul-bytes` ("no raw ASCII control bytes"), `check:engine-double-contract`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage`, `check:type-check-debt`, the changeset gates (`check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`) and `check:doc-authoring`. - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET. The gate reads every publishable package's `dist/`, and only plugin-auth's dependency closure is built here; a whole-workspace build is CI's. Targeted substitute: `require('@objectstack/plugin-auth')` from the package resolves `dist/index.js` and loads (`AuthPlugin: function`, exit 0). The diff adds no import. ## Acceptance notes - PR #22325's gate population is hook handlers. The `objectql` middleware path (`registerMiddleware` → `runEnsure` → `runBackfillOnDefaultOrg`) can call `runBackfill` during Phase 2, and the gate does not model it; there, `backfillArmed` is the only guard. This is an observation about the gate's reach, not a defect: the flag keeps that path out of the pre-bind window at runtime. Owner: none. --- _Generated by [Claude Code](https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3599fef commit 8a995b8

3 files changed

Lines changed: 38 additions & 10 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
The auth plugin registers its `app:seeded` membership-backfill handler when its backfill is armed, not when the plugin starts
6+
7+
Clause-②: no
8+
9+
The one-time membership backfill (ADR-0093 D6) re-runs on `app:seeded`, so users written by a seed that settles after `kernel:ready` still get a membership. Its handler used to be registered in `start()` and kept from acting early by a runtime flag: until the backfill's own `kernel:ready` hook armed it, the handler returned without doing anything. The handler is now registered by that `kernel:ready` hook, at the moment it arms the backfill, so it does not exist before the settings engine binds. The boot-ordering gate reads this from the source, which it cannot do with a flag.
10+
11+
- **What the backfill does is unchanged.** An `app:seeded` that fires before the backfill is armed still does nothing, and one that fires after it still re-runs the pass.
12+
- **No option, setting or export changes.** `OS_SKIP_MEMBERSHIP_BACKFILL=1` still registers no `app:seeded` handler at all.

‎packages/plugins/plugin-auth/src/auth-plugin.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1635,9 +1635,13 @@ describe('AuthPlugin', () => {
16351635
await authPlugin.start(mockContext);
16361636
};
16371637

1638-
it('registers an app:seeded hook alongside kernel:ready', async () => {
1638+
it('registers its app:seeded hook from the arming kernel:ready handler, never from start()', async () => {
1639+
// #22257 — the handler cannot run before the settings engine binds
1640+
// because it does not exist before this plugin's kernel:ready handler.
16391641
await boot();
1640-
expect(mockContext.hook).toHaveBeenCalledWith('app:seeded', expect.any(Function));
1642+
expect(hookCapture.handlers.get('app:seeded') ?? []).toHaveLength(0);
1643+
await hookCapture.trigger('kernel:ready');
1644+
expect(hookCapture.handlers.get('app:seeded') ?? []).toHaveLength(1);
16411645
});
16421646

16431647
it('app:seeded runs the one-time pass when kernel:ready had no target organization yet', async () => {

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,16 +1352,28 @@ export class AuthPlugin implements Plugin {
13521352
runBackfillOnDefaultOrg = runBackfill;
13531353
ctx.hook('kernel:ready', () => {
13541354
backfillArmed = true;
1355+
// #2996: app seeds insert `sys_user` via raw engine.insert, bypassing
1356+
// better-auth's `user.create.after` reconciler. A seed that overruns
1357+
// OS_INLINE_SEED_BUDGET_MS finishes in the background AFTER kernel:ready,
1358+
// so its users would miss a kernel:ready pass that had no target yet.
1359+
// The trigger stays; the ledger makes it a no-op once the one-time pass
1360+
// has been recorded. An in-budget seed settles during Phase 2, before the
1361+
// pass is armed, and the `kernel:ready` pass covers its rows.
1362+
//
1363+
// [#22257] Registered HERE, in the same synchronous step that arms the
1364+
// pass, never from `start()`: a handler that does not exist before the
1365+
// bind cannot run before it. Every `app:seeded` that fires before this
1366+
// point was already a no-op through `backfillArmed`, and every one after
1367+
// it reaches this handler — the kernel's hook map is read at dispatch
1368+
// time, so an emit still in flight picks it up too. The structure is
1369+
// what `check:settings-bind-window` can see; a flag is not.
1370+
// `backfillArmed` stays: the `default-org-created` trigger
1371+
// (`runBackfillOnDefaultOrg`) can still reach `runBackfill` from the
1372+
// `objectql` middleware during Phase 2 and from the bootstrap's own
1373+
// `kernel:ready` hook, which runs before this one.
1374+
ctx.hook('app:seeded', () => runBackfill('app:seeded'));
13551375
return runBackfill('kernel:ready');
13561376
});
1357-
// #2996: app seeds insert `sys_user` via raw engine.insert, bypassing
1358-
// better-auth's `user.create.after` reconciler. A seed that overruns
1359-
// OS_INLINE_SEED_BUDGET_MS finishes in the background AFTER kernel:ready,
1360-
// so its users would miss a kernel:ready pass that had no target yet.
1361-
// The trigger stays; the ledger makes it a no-op once the one-time pass
1362-
// has been recorded. An in-budget seed settles during Phase 2, before the
1363-
// pass is armed, and the `kernel:ready` pass covers its rows.
1364-
ctx.hook('app:seeded', () => runBackfill('app:seeded'));
13651377
}
13661378

13671379
// Identity-source provenance for accounts created OUTSIDE better-auth's

0 commit comments

Comments
 (0)