Skip to content

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 into
mainfrom
claude/issue-22316-settings-bind-window-phase2
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22316-settings-bind-window-phase2

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #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 #22316) was implemented by #22328 in PR #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 #22312 merged in, runtime flag backfillArmed) in start() red at auth-plugin.ts:1591 [app:seeded-hook-from-start]
db9b7d2ad (PR #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-fix(plugin-auth): a seeded boot no longer reads the auth settings before the settings engine binds (#22257) #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.

claude added 5 commits October 8, 2026 16:31
…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>
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
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 20:11
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 20:11
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit c6fc938 Oct 8, 2026
40 checks passed
@objectstack-fleet
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants