Repository navigation
fix(plugin-auth): register the app:seeded backfill handler from the arming kernel:ready handler (#22328) - #22333
Merged
objectstack-fleet[bot] merged 1 commit intoOct 8, 2026
Conversation
…rming kernel:ready handler The ADR-0093 D6 backfill's app:seeded handler was registered in start() and kept from acting before the settings engine binds by the backfillArmed runtime flag, which a static walk cannot see. It is now registered by the kernel:ready handler that arms the pass, in the same synchronous step, so it does not exist before the bind. backfillArmed stays: the default-org-created trigger still reaches runBackfill before arming. The unit test that pinned the start()-time registration now pins the new structure: no app:seeded handler after start(), one after kernel:ready. Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
objectstack-fleet
Bot
deleted the
claude/issue-22328-plugin-auth-seeded-hook-structural
branch
October 8, 2026 19:29
This was referenced Oct 8, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Oct 9, 2026
…ed before the bind, and follow promise continuations (objectstack-ai#22325) Fixes objectstack-ai#22316 Clause-②: no `check:settings-bind-window` now judges the handlers of every hook a plugin fires before the settings bind, not only `kernel:ready` handlers, and its walk follows promise continuations. With that, the `plugin-auth` `app:seeded` reader the gate used to miss scored red, which is what the triage pin asked for. **Outcome.** Seat decision (a) (comment 6065407005 on objectstack-ai#22316) was implemented by objectstack-ai#22328 in PR objectstack-ai#22333, merged as `8a995b8a98`. plugin-auth now registers its `app:seeded` handler inside the arming `kernel:ready` handler (`packages/plugins/plugin-auth/src/auth-plugin.ts:1374` on `main`). On the merged head `db9b7d2ad`, the widened gate reads **green on the real tree with no ledger entry**: `KNOWN_PRE_BIND_READS` is still `[]`, and this PR adds no declaration for plugin-auth. plugin-auth's reads are judged `declared` through its existing ordering on the settings plugin: ``` ✓ settings bind-window guard self-test: all cases pass. ✓ 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'). ``` The PR stays a draft; landing is the seat's. ## What changed (one file: `scripts/check-settings-bind-window.mjs`) 1. **Population: pre-bind hooks.** `PRE_BIND_HOOKS` names the 8 hooks plugins fire from their own `init()` / `start()` (Phase 1/2), each with its fire site. A handler of one of them registered from `init()` / `start()` gets the origin `HOOK-hook-from-PHASE`, which is never the declaration-fixable sub-window. A handler registered from inside a `kernel:ready` handler inherits that handler's window instead, because it cannot exist before that handler runs. That is the structural shape plugin-auth now uses. 2. **Derivation and pin, checked both ways.** `audit()` re-derives the fired set on every run from every `.trigger(NAME, ...)` on the plugin context and every `trigger.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: - a fired name that is not pinned (so a new one is not missed again); - a pinned name that is no longer fired (a stale pin; an empty derivation stales every row, which is the anti-vacuity limb); - a fire site whose name cannot be resolved. Only a `.trigger` on the plugin context counts. The context is tracked through the lifecycle methods' first parameter, the helper parameters it is passed to, and `this.X = ctx` fields. A job or flow service's `.trigger(name)` is not a hook. 3. **Walk: promise continuations.** Callbacks passed to `.then` / `.catch` / `.finally` are 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). 4. **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 () => ...)` in `runBackfill`, 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. | gate variant, real tree at base `4e4111ca0` | verdict | |---|---| | base gate | green: `4 declared / 0 self / 1 structurally upstream / 0 ledgered (73 plugin unit(s) scanned)` | | + pre-bind hooks only | green, no new read | | + pre-bind hooks + continuations (this PR) | red, one read: `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 `--list` at the merged head `db9b7d2ad`) | hook | fire site | origin | in-repo handlers | |---|---|---|---| | `app:seeded` | `packages/runtime/src/app-plugin.ts:1588`, `AppPlugin.start()` calls `emitSeedSettled(false)`, which calls `trigger.call(ctx, 'app:seeded', ...)` | start-body | 4 | | `app:registered` | `packages/runtime/src/app-plugin.ts:1921`, `AppPlugin.start()` calls `this.emitCatalogEvent(ctx, 'app:registered', sys)`, which calls `trigger.call(ctx, event, payload)` | start-body | 0 | | `auth:configure` | `packages/plugins/plugin-auth/src/auth-plugin.ts:550`, `AuthPlugin.init()` | init-body | 0 | | `automation:ready` | `packages/services/service-automation/src/plugin.ts:950`, `AutomationServicePlugin.start()` | start-body | 0 | | `analytics:ready` | `packages/services/service-analytics/src/plugin.ts:1573`, `AnalyticsServicePlugin.start()` | start-body | 0 | | `datasource-admin:ready` | `packages/services/service-datasource/src/datasource-admin-plugin.ts:593`, `DatasourceAdminServicePlugin.start()` | start-body | 0 | | `external-datasource:ready` | `packages/services/service-datasource/src/plugin.ts:273`, `ExternalDatasourceServicePlugin.start()` | start-body | 0 | | `mcp:ready` | `packages/mcp/src/plugin.ts:682`, `MCPServerPlugin.start()` | start-body | 0 | 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:reloaded` and `external.schema.drift` fire from watchers and timers that no lifecycle body reaches. - `ai:routes` has an out-of-repo emitter. ## Real tree across the three heads | head | plugin-auth `app:seeded` registration | `check:settings-bind-window` | |---|---|---| | base `4e4111ca0`, base gate | in `start()` | green, because the gate did not see the read | | `96d46ea02` (PR objectstack-ai#22312 merged in, runtime flag `backfillArmed`) | in `start()` | red at `auth-plugin.ts:1591 [app:seeded-hook-from-start]` | | `db9b7d2ad` (PR objectstack-ai#22333 merged in) | inside the arming `kernel:ready` handler | **green**, verdict quoted above, 0 ledgered | On `db9b7d2ad`, `--list` judges plugin-auth `declared` at `auth-plugin.ts:1603` and `:959`, both `ready-hook-from-start`. No read is left in an `app:seeded-hook-from-start` window. ## Self-test: new cases 17 to 22 (all cases pass at `db9b7d2ad`) - **17, positive control.** The pre-objectstack-ai#22312 plugin-auth shape: an `app:seeded` handler registered in `start()`, the ordering declared, and the read inside a `.then` continuation three calls down. Result: red, `unfixable-by-declaration`, origin `app:seeded-hook-from-start`. - **17b, each arm alone.** A direct-path `app:seeded` reader is red. A continuation inside a `kernel:ready` handler is red. - **18, negative control.** A `kernel:bootstrapped` reader is green. - **18b, the structural repair.** An `app:seeded` handler registered from a declared `kernel:ready` handler is green (`declared`); this is the shape plugin-auth adopted. The same handler without the declaration gets `undeclared`. - **19, every pinned hook.** Each pinned hook, registered from `init()` and from `start()`, is red. The cases are enumerated from the pin itself, and `app:seeded` must stay pinned. - **20, unpinned hook.** A handler of an unpinned post-boot hook (`metadata:reloaded`) is green. - **21, derivation.** Each fire-site spelling is derived. Deferred (`setTimeout`), post-bind (`kernel:bootstrapped`) and off-context (`jobs.trigger`) fire sites are ignored. An unresolvable name is recorded. - **22, reconciliation.** Drift is caught in both directions, and an empty derivation stales every pin. ## Ablations Run at heads `91d5e5c3b` to `41f8a64f6`, before the merges. The fix was committed before each ablation. Each mutation was made and restored by `scripts/ablation-replace.mjs`, which checks that the anchor landed and that the restored blob equals HEAD with an empty `git diff HEAD`. | # | mutation | self-test | real tree at the time | |---|---|---|---| | A1 | continuations not entered | ✗ case 17 `(got 0)` | green | | A2 | population back to `kernel:ready` only | ✗ case 17 `(got 0)` | green | | A3 | `trigger.call` spelling not derived | ✗ case 21 | ✗ stale pins `app:registered`, `app:seeded` | | A4 | pin row `mcp:ready` renamed | not run | ✗ `mcp:ready` fired at `packages/mcp/src/plugin.ts:682` and unpinned; `mcp:ready-renamed` stale | | A5 | continuation loses the enclosing parameter bindings | ✗ case 21 (`x:then-param` missing) | not run | | A6 | context restriction off | ✗ case 21 (`nightly-cleanup` derived) | not run | ## Gates (merged head `db9b7d2ad`, merge base `e9a1f5c40`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 30 commands: 1 path changed, 573 changed lines. - Every one of the 30 exits 0, `pnpm check:settings-bind-window` included (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 --ran` reconciles: `✓ dispatch-gates --ran: 30 derived famil(ies) accounted for — 30 run, 0 NOT-MEASURED`. - No package is touched, so there is no build closure. - Root `scripts/` publishes nothing, so there is no changeset (`skip-changeset`). - Runtime goes from about 2.3 s to about 3.8 s, because `trigger` joins the parse prefilter. ## Acceptance notes - This gate's `--self-test` has the verdict handshake but no battery-name floor (AGENTS.md "Writing a `--self-test`"). `docs/audits/2026-09-self-test-shape-census.md` lists it as floor NONE. I did not retrofit it here. - The spec's `IPluginLifecycleEvents` omits `app:registered`, which `AppPlugin.start()` fires through `emitCatalogEvent`. 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. - The gate header's description of the live `runBackfill` case was out of date: the call now sits inside a continuation. The header is corrected in this PR. Written by the `domain:devx` seat 1 dispatch, session `session_0115N1oNnQS5WqofZ2DzaT3q`; this body was edited once after the seat decision, through the fleet relay. --------- 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 #22328
Clause-②: no
The
plugin-authhalf 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'sapp:seededhandler used to be registered fromstart(). Until the pass was armed, thebackfillArmedruntime flag made it a no-op (PR #22312). It is now registered by thekernel:readyhandler that arms the pass, in the same synchronous step:This is the seat's ruling on #22316 (comment 6065407005): "Decision: (a).
plugin-authregisters theapp:seededhandler from inside the armingkernel:readyhandler, which is the flag's guarantee made structural."auth-plugin.ts. No other package changes.backfillArmedstays. It is not dead after the move. Thedefault-org-createdtrigger (runBackfillOnDefaultOrg) still callsrunBackfillbefore arming from two places. One is theobjectqlmiddleware, which a Phase-2sys_userseed insert can trigger. The other is the bootstrap's ownkernel:readyhook (runEnsure), which is registered earlier instart()and so runs before the arming handler.auth-plugin.test.tshad a test, "registers an app:seeded hook alongside kernel:ready", that checked the hook was registered right afterinit()+start(), which is the old structure. It now checks the new structure: noapp:seededhandler afterstart(), and exactly one afterkernel:readyfires. Neither fix(plugin-auth): a seeded boot no longer reads the auth settings before the settings engine binds (#22257) #22312 pin changed.@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'shooksmap, creating the entry if needed.trigger()readsthis.hooks.get(name)when it dispatches. Sources:packages/core/src/kernel.tslines 198-209 (ObjectKernel),kernel-base.tslines 118-128 (LiteKernel), andsecurity/plugin-permission-enforcer.tslines 453-456, which delegates.dispatchHookPropagating(hook-dispatch.ts) loopsfor...ofover 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:seededemit 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 stubbedundecided, so everyrunBackfillthat actually ran counts as one visible call:app:seededhandlers at astart()-time emit / after boot (ObjectKernel and LiteKernel)start()-registered subscriber whose plugin starts after authSo 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.runBackfill('app:seeded')behind thekernel:readypass. That extra call stops atbackfillDecidedonce 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.app:seededsubscriber registered instart()by a plugin that starts after auth. Before, it ran ahead of such subscribers.app:seededsubscribers in this repo areplugin-security(registered ininit()), the CLI seed-settlement announcer (init()),platform-objects(start()) and this one.os servecomposesPlatformObjectsPluginbeforeAuthPlugin(serve.ts3945 vs 4102), and no ordering edge reverses that. So the subscriber order onos serveis the same in both shapes, and no subscriber sits after the auth slot. Cloud-held compositions: NOT MEASURED.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 treeI copied the gate into a scratch root. Its
scripts/held symlinks to this worktree's scripts except for that one file, andpackagesandnode_moduleswere symlinks to this worktree. The script's md5 matchedgit show 96d46ea02d:scripts/check-settings-bind-window.mjs(dbd45f65ff759cded2ddbcf7e349d026). It is not committed here.Control, unmodified
origin/main28bff18d0c: exit 1This tree, head
9c8ed2b62e: exit 0, no ledger entryAblation, with the fix committed at
9c8ed2b62e.scripts/ablation-replace.mjsin WRAP mode moved the registration back out of the handler to itsstart()-time position. It reported "anchor 1 -> 0, blob 49113b925d97 -> 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, atauth-plugin.ts:1603 [app:seeded-hook-from-start]. Restore: "blob == HEAD (49113b925d97) andgit diff HEADis empty". My own check agreed:git hash-object=49113b925d9785632b45ea864d83f97d8a41b70f= theHEAD:blob, andgit statuswas clean.Tests (head
9c8ed2b62e)pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-auth^...' build, VERDICT command-exit 0, 29 of 81 projects. Thenpnpm --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).auth-settings-seeded-boot.pin.test.ts2/2 (no Pre-bind READ; the control reports exactly 1),auth-settings-ordering.pin.test.ts5/5. Also theMembership backfill re-run on app:seeded (#2996)block 11/11 andmembership-policy-setting.test.ts. Together: 4 files, 123 tests passed.Gates (head
9c8ed2b62e)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 67 commands for 3 paths (48 changed lines). I ran each with its exit code captured before any pipe, pluspnpm check:settings-bind-window(this tree's own version: exit 0, "4 declared / 0 self / 1 structurally upstream / 0 ledgered").--ranreports: "✓ dispatch-gates --ran: 67 derived famil(ies) accounted for — 66 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)".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) andcheck:doc-authoring.pnpm check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET. The gate reads every publishable package'sdist/, 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 resolvesdist/index.jsand loads (AuthPlugin: function, exit 0). The diff adds no import.Acceptance notes
objectqlmiddleware path (registerMiddleware→runEnsure→runBackfillOnDefaultOrg) can callrunBackfillduring Phase 2, and the gate does not model it; there,backfillArmedis 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