Repository navigation
fix(plugin-auth): a seeded boot no longer reads the auth settings before the settings engine binds (#22257) - #22312
Merged
objectstack-fleet[bot] merged 4 commits intoOct 8, 2026
Conversation
…y, so a Phase-2 app:seeded no longer reads the auth settings before the engine bind
AppPlugin.start() emits app:seeded when its inline seed lands. On os serve /
os dev the auth plugin starts first, so its app:seeded handler ran the
ADR-0093 D6 pass during Phase 2, and the pass's ensureAuthSettingsBound read
getNamespace('auth') before SettingsServicePlugin's kernel:ready hook bound the
data engine: the showcase boot logged one SettingsService Pre-bind READ for
namespace auth, and the binding was computed from manifest defaults.
The pass is now armed by its own kernel:ready hook. A trigger before that is a
no-op; the kernel:ready pass, ordered after the settings bind by the existing
optionalDependencies edge, covers every row such a trigger was about.
Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…d reads the persisted auth settings
A real ObjectKernel with in-memory SQLite, the real SettingsServicePlugin, the
real AuthPlugin used before it (the os serve order), and an app plugin that
emits app:seeded from its start(). Asserts the event fires inside the pre-bind
window, that no Pre-bind READ is reported, that the auth binding's first
getNamespace('auth') answers the persisted membership_policy, and that the
one-time pass decides under it. A control forces a pre-bind read in the same
composition and expects exactly one report.
@objectstack/service-settings becomes a devDependency of plugin-auth, aliased
to its src in vitest.config.ts. One patch changeset for plugin-auth.
Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
Contributor
📓 Docs Drift Check
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): |
…service-settings is not a dependency The seeded-boot pin made @objectstack/service-settings a devDependency, aliased to src, so the note's "not a dependency ... would force a new registry entry" became false. The stub's own justification (resolvePluginOrder reads only names and declarations) stands; the note now points at the pin that composes the real plugin. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Oct 8, 2026
This was referenced Oct 8, 2026
This was referenced Oct 8, 2026
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>
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 #22257
Clause-②: no
What was wrong
On
examples/app-showcase,os dev --seed-admin --freshlogged one[SettingsService] Pre-bind READ of namespace 'auth'on every boot. Re-measured onorigin/main79c35d45(after PR #22295): 1 line, in the boot-diagnostics block.The reader. A temporary stack capture in the built
reportPreBindRead(applied toservice-settings/dist/index.jsand reverted; the sha256 matched before and after, and the marker count after the revert was 0) named it:SettingsService.getNamespacefromAuthPlugin.bindAuthSettings, thenapplySettings(auth-plugin.ts:1535at79c35d45), fromAuthPlugin.ensureAuthSettingsBound, fromrunBackfill(:1235/:1244), from the kernel'strigger.The handler is the
app:seededhook registered inAuthPlugin.start()(:1298), which callsrunBackfill('app:seeded'). It is the ADR-0093 D6 one-time membership backfill.The phase. A debug-level boot puts the read in Phase 2 (start). It comes after
[Seeder] Seed loading complete(132 rows,plugin.app.com.example.showcase) and beforeTriggering kernel:ready hook.AppPlugin.start()emitsapp:seededwhen its inline seed lands. Onos dev, the auth plugin started first, so its handler ran during Phase 2.SettingsServicePluginbinds the engine in itskernel:readyhook (settings-service-plugin.ts:221), which is later.The
optionalDependencies: ['com.objectstack.service.settings']edge onAuthPluginorders onlykernel:readyHOOKS. An event fired during Phase 2 is outside it.The consequence.
ensureAuthSettingsBoundis memoized, so the auth binding was computed from the manifest defaults. In the debug log,Auth: bound to settings namespace=authappears at 14:18:11.661, beforekernel:readyat 11.672. The persisted rows were re-applied later only through a fire-and-forget subscription callback. The one-time pass's first run read the default policyauto.Why
check:settings-bind-windowmissed it. The gate walksinit()/start()bodies andkernel:readyhandlers only (READY_HOOK). It reached this same read through thekernel:readyregistration ofrunBackfill, and classified itdeclared. Theapp:seededregistration fires during Phase 2, from another plugin'sstart(), and is not in the gate's population. This is reported as a blind spot below; this PR adds no gate.The fix (
packages/plugins/plugin-auth/src/auth-plugin.ts)The one-time pass is now armed by its own
kernel:readyhook. Any trigger before that does nothing:app:seededduring Phase 2, ordefault-org-createdfrom the bootstrap middleware. Thekernel:readypass still runs afterwards, after the settings bind, and scans every row such a trigger was about. Triggers afterkernel:readybehave exactly as before. That includes an over-budget seed that settles in the background (the #2996 path).packages/specchange.ensureAuthSettingsBoundinrunBackfillwas stale: it said "registered ininit()". It is corrected.Boot, before and after
Pre-bind READlinesAuth: bound to settings namespace=auth79c35d45(base)auth)kernel:readytrigger9b7ac6cf(fix; plugin-auth dist rebuilt,backfillArmedgrep 3 indist/index.mjs)kernel:readytrigger (16.538 vs 16.381)In both boots the D6 pass still records
adr-0093-membership-backfillonce. The other boot-diagnostic lines are unchanged:sys_migrationUNIQUE and[sharing-rule]. See the acceptance notes.Pins
packages/plugins/plugin-auth/src/auth-settings-seeded-boot.pin.test.tscomposes:ObjectKernelSettingsServicePluginAuthPlugin, used before the settings plugin (theos serveorder)AppPlugin's place: it writes the persistedauth.membership_policy = invite-onlyrow, then emitsapp:seededfrom itsstart().The dogfood harness cannot compose this.
bootStackregistersAppPluginbeforeAuthPlugin, soapp:seededfires before auth registers its handler.The main case asserts four things:
app:seeded, the settings engine is unbound.Pre-bind READis reported.getNamespace('auth')answers{ value: 'invite-only', source: 'global' }.policy: 'invite-only'.A control case forces a pre-bind read of
authin the same composition and expects exactly one report.@objectstack/service-settingsis added as a devDependency of plugin-auth. It is aliased tosrc/invitest.config.ts(anchored, like the three existing entries), soKNOWN_UNALIASED_TEST_IMPORTSis unchanged. The lockfile gains only that importer entry.Control.
settings-prebind-read-warning.test.tspasses 9/9 unchanged. It still fires on a forced pre-bind read.Ablation, via
scripts/ablation-replace.mjsfrom committedd907051b, with an EXIT/INT/TERM restore and the restore proven by blob == HEAD and an emptygit diff HEAD.AuthPluginis imported relatively, sosrc/is what runs and no dist rebuild was involved.if (!backfillArmed) return backfillChain;(anchor 1 to 0, blobd559d607to83e4649d). The main case goes RED at assertion 2: onePre-bind READ of namespace 'auth'. The control stays green. Restored tod559d607.authread answered{ value: 'auto', source: 'default' }. Both files restored (blob == HEAD).Verification (the gate union was run on
b67e95f1)pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: 129 files, 2639 passed, 10 skipped, exit 0. This ran ond907051b. The merge after it brought onlydocs/adr/0096.pnpm --filter @objectstack/plugin-auth run typecheck: exit 0.check:test-typecheckis OK, and the new file is in thetsconfig.test.jsonprogram (--listFiles: 1).node scripts/pm/dispatch-gates.mjs --commands(no paths) atb67e95f1derived 79 commands, and all 79 exited 0.--ran: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN.pnpm check:settings-bind-window:4 declared / 0 self / 1 structurally upstream / 0 ledgered (73 plugin unit(s) scanned).check-test-source-alias OK — 73 packages with tests scanned; 60 registeredcheck:workspace-manifest-cycles OK: 80 workspace package(s), 515 workspace: edge(s)check-nul-bytes: OKcheck:undeclared-dep-importspassedcheck-adr-0087-registration: 1 non-breaking changeset(s) seen--format json. Each file has a non-empty rule set under--print-config.eslint.config.mjsenables no type-aware linting, so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis left to CI.Acceptance notes
Gate blind spot (not fixed; no gate added).
check:settings-bind-windowcannot see two things:kernel:readythat fires during Phase 2 (app:seeded, emitted fromAppPlugin.start());Its header says "Everything that runs before that bind hook is the window." Its population is narrower than that window.
Landing order with PR feat(plugin-auth,objectql,metadata-protocol,runtime)!: under
singlethe Default Organization exists before the seeds and the listener; an unowned seed row or system write is derived there or refused (ADR-0131 C1) #22186. That PR makesAppPlugindeclarecom.objectstack.authas an optional dependency, and creates the Default Organization inAuthPlugin.start(). Without this fix, the Phase-2app:seededpass would then have a target and would DECIDE the one-time backfill under the manifest-default policy. This fix arms the pass atkernel:ready, so it holds under either landing order. The two diffs touch different regions ofauth-plugin.ts: theirs is around:725and:1168, this one is:1232to:1316.Unchanged boot line.
WARN Insert operation failed {"object":"sys_migration", … UNIQUE constraint failed: sys_migration.id}appears on the fresh showcase boot both before and after this change. It comes right afteradr-0093-default-org-owner-bindis recorded. Root cause is NOT MEASURED; it is reported to the seat.Merge state.
origin/mainwas merged at799eb000.mainhas since moved 4 commits. None touches plugin-auth or service-settings; plugin-security's strict mode is the nearest, and this composition mounts noSecurityPlugin.Generated by Claude Code