Repository navigation
fix(check:settings-bind-window): judge the handlers of every hook fired before the bind, and follow promise continuations - #22325
Merged
objectstack-fleet[bot] merged 6 commits intoOct 8, 2026
Conversation
…probe toggle in place) Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
…ed before the bind, and follow promise continuations The population was init()/start() bodies plus kernel:ready handlers, so a settings read from an app:seeded handler registered in start() scored green while the boot logged a Pre-bind READ. PRE_BIND_HOOKS now names the hooks plugins fire in Phase 1/2 (eight, each with its fire site), the audit re-derives that set from the source and refuses on drift in either direction, and the walk enters .then/.catch/.finally continuations, the one nested body that runs inside the window it is in. Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
…rame's bound hook-name parameters Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
… fires a kernel hook A job or flow service's .trigger(name) reached from start() is not a hook; reading it as one would refuse a correct plugin with a remedy that does not apply. The context is tracked through the lifecycle methods' first parameter, helper parameters it is handed to, and this.X = ctx fields. Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Oct 8, 2026
Brings in the structural app:seeded registration in plugin-auth that the widened check:settings-bind-window population reads as post-bind. Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
objectstack-fleet
Bot
deleted the
claude/issue-22316-settings-bind-window-phase2
branch
October 8, 2026 20:35
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Oct 9, 2026
…rming kernel:ready handler (objectstack-ai#22328) (objectstack-ai#22333) Fixes objectstack-ai#22328 Clause-②: no The `plugin-auth` half of the parent card objectstack-ai#22316. The gate half is PR objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 (objectstack-ai#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 objectstack-ai#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 (objectstack-ai#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 objectstack-ai#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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #22316
Clause-②: no
check:settings-bind-windownow judges the handlers of every hook a plugin fires before the settings bind, not onlykernel:readyhandlers, and its walk follows promise continuations. With that, theplugin-authapp:seededreader the gate used to miss scored red, which is what the triage pin asked for.Outcome. Seat decision (a) (comment 6065407005 on #22316) was implemented by #22328 in PR #22333, merged as
8a995b8a98. plugin-auth now registers itsapp:seededhandler inside the armingkernel:readyhandler (packages/plugins/plugin-auth/src/auth-plugin.ts:1374onmain). On the merged headdb9b7d2ad, the widened gate reads green on the real tree with no ledger entry:KNOWN_PRE_BIND_READSis still[], and this PR adds no declaration for plugin-auth. plugin-auth's reads are judgeddeclaredthrough its existing ordering on the settings plugin:The PR stays a draft; landing is the seat's.
What changed (one file:
scripts/check-settings-bind-window.mjs)Population: pre-bind hooks.
PRE_BIND_HOOKSnames the 8 hooks plugins fire from their owninit()/start()(Phase 1/2), each with its fire site. A handler of one of them registered frominit()/start()gets the originHOOK-hook-from-PHASE, which is never the declaration-fixable sub-window. A handler registered from inside akernel:readyhandler inherits that handler's window instead, because it cannot exist before that handler runs. That is the structural shape plugin-auth now uses.Derivation and pin, checked both ways.
audit()re-derives the fired set on every run from every.trigger(NAME, ...)on the plugin context and everytrigger.call(CTX, NAME, ...)reached from a lifecycle body or a pre-bind handler. A hook name passed in as a literal argument is resolved through the helper's parameter (emitCatalogEvent(ctx, 'app:registered', sys)). The audit refuses in three cases:Only a
.triggeron the plugin context counts. The context is tracked through the lifecycle methods' first parameter, the helper parameters it is passed to, andthis.X = ctxfields. A job or flow service's.trigger(name)is not a hook.Walk: promise continuations. Callbacks passed to
.then/.catch/.finallyare walked in the enclosing window, because they run when a promise the phase started settles and the kernel awaits each handler. The walk still does not enter any other nested function (case 9 still holds).Refusal text. For a pre-bind-hook read, the refusal names the hook's fire site and the structural remedy that plugin-auth adopted.
Premise check (measured at base
4e4111ca0)The card's diagnosis covered only half the cause. Adding the hooks to the population alone left the real tree green. The reason: plugin-auth's read sat inside
backfillChain.then(async () => ...)inrunBackfill, a nested function the walk did not enter. The reader scored red only with both arms. Self-test case 17 is written so that either arm alone fails it.4e4111ca04 declared / 0 self / 1 structurally upstream / 0 ledgered (73 plugin unit(s) scanned)auth-plugin.ts:1525 [app:seeded-hook-from-start]On that tree, entering continuations added exactly that one read and no other.
The pinned hooks (derived; line numbers from
--listat the merged headdb9b7d2ad)app:seededpackages/runtime/src/app-plugin.ts:1588,AppPlugin.start()callsemitSeedSettled(false), which callstrigger.call(ctx, 'app:seeded', ...)app:registeredpackages/runtime/src/app-plugin.ts:1921,AppPlugin.start()callsthis.emitCatalogEvent(ctx, 'app:registered', sys), which callstrigger.call(ctx, event, payload)auth:configurepackages/plugins/plugin-auth/src/auth-plugin.ts:550,AuthPlugin.init()automation:readypackages/services/service-automation/src/plugin.ts:950,AutomationServicePlugin.start()analytics:readypackages/services/service-analytics/src/plugin.ts:1573,AnalyticsServicePlugin.start()datasource-admin:readypackages/services/service-datasource/src/datasource-admin-plugin.ts:593,DatasourceAdminServicePlugin.start()external-datasource:readypackages/services/service-datasource/src/plugin.ts:273,ExternalDatasourceServicePlugin.start()mcp:readypackages/mcp/src/plugin.ts:682,MCPServerPlugin.start()The list is kept by hand but checked by machine. The kernel fires only
kernel:*hooks, and only after Phase 2, so the Phase-2 names exist only at the plugins' fire sites. The audit re-derives them on every run and refuses on drift in either direction (ablations A3 and A4 below). Not derived:metadata:reloadedandexternal.schema.driftfire from watchers and timers that no lifecycle body reaches.ai:routeshas an out-of-repo emitter.Real tree across the three heads
app:seededregistrationcheck:settings-bind-window4e4111ca0, base gatestart()96d46ea02(PR #22312 merged in, runtime flagbackfillArmed)start()auth-plugin.ts:1591 [app:seeded-hook-from-start]db9b7d2ad(PR #22333 merged in)kernel:readyhandlerOn
db9b7d2ad,--listjudges plugin-authdeclaredatauth-plugin.ts:1603and:959, bothready-hook-from-start. No read is left in anapp:seeded-hook-from-startwindow.Self-test: new cases 17 to 22 (all cases pass at
db9b7d2ad)app:seededhandler registered instart(), the ordering declared, and the read inside a.thencontinuation three calls down. Result: red,unfixable-by-declaration, originapp:seeded-hook-from-start.app:seededreader is red. A continuation inside akernel:readyhandler is red.kernel:bootstrappedreader is green.app:seededhandler registered from a declaredkernel:readyhandler is green (declared); this is the shape plugin-auth adopted. The same handler without the declaration getsundeclared.init()and fromstart(), is red. The cases are enumerated from the pin itself, andapp:seededmust stay pinned.metadata:reloaded) is green.setTimeout), post-bind (kernel:bootstrapped) and off-context (jobs.trigger) fire sites are ignored. An unresolvable name is recorded.Ablations
Run at heads
91d5e5c3bto41f8a64f6, before the merges. The fix was committed before each ablation. Each mutation was made and restored byscripts/ablation-replace.mjs, which checks that the anchor landed and that the restored blob equals HEAD with an emptygit diff HEAD.(got 0)kernel:readyonly(got 0)trigger.callspelling not derivedapp:registered,app:seededmcp:readyrenamedmcp:readyfired atpackages/mcp/src/plugin.ts:682and unpinned;mcp:ready-renamedstalex:then-parammissing)nightly-cleanupderived)Gates (merged head
db9b7d2ad, merge basee9a1f5c40)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 30 commands: 1 path changed, 573 changed lines.pnpm check:settings-bind-windowincluded (verdict above).pnpm check:pm-dispatch-gates: exit 0,✓ dispatch-gates self-test: 1976 cases pass.(battery 983.2 s, run detached, exit code captured to a file).dispatch-gates --ranreconciles:✓ dispatch-gates --ran: 30 derived famil(ies) accounted for — 30 run, 0 NOT-MEASURED.scripts/publishes nothing, so there is no changeset (skip-changeset).triggerjoins the parse prefilter.Acceptance notes
--self-testhas the verdict handshake but no battery-name floor (AGENTS.md "Writing a--self-test").docs/audits/2026-09-self-test-shape-census.mdlists it as floor NONE. I did not retrofit it here.IPluginLifecycleEventsomitsapp:registered, whichAppPlugin.start()fires throughemitCatalogEvent. Its pinning test (plugin-lifecycle-events.test.ts) compares the interface against a hand-written list, not against the source's fire sites. The derivation above is a mechanical fire-site inventory for Phase-1/2 emits. Noted, not filed.runBackfillcase was out of date: the call now sits inside a continuation. The header is corrected in this PR.Written by the
domain:devxseat 1 dispatch, sessionsession_0115N1oNnQS5WqofZ2DzaT3q; this body was edited once after the seat decision, through the fleet relay.