Skip to content

fix(plugin-auth): register the app:seeded backfill handler from the arming kernel:ready handler (#22328) - #22333

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-22328-plugin-auth-seeded-hook-structural
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-22328-plugin-auth-seeded-hook-structural

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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:

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 fix(plugin-auth): a seeded boot no longer reads the auth settings before the settings engine binds (#22257) #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 96d46ea02d:scripts/check-settings-bind-window.mjs (dbd45f65ff759cded2ddbcf7e349d026). It is not committed here.

Control, unmodified origin/main 28bff18d0c: exit 1

✗ 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

✓ 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 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, at auth-plugin.ts:1603 [app:seeded-hook-from-start]. Restore: "blob == HEAD (49113b925d97) 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 fix(plugin-auth): a seeded boot no longer reads the auth settings before the settings engine binds (#22257) #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


Generated by Claude Code

…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>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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): node scripts/docs-audit/affected-docs.mjs --json 28bff18d0c4013db86d61eba87c739c3145e17fd → packageMentionDocs.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 18:59
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 18:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 8a995b8 Oct 8, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22328-plugin-auth-seeded-hook-structural branch October 8, 2026 19:29
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s tests tooling

Projects

None yet

2 participants