Repository navigation
Commit c6fc938
fix(check:settings-bind-window): judge the handlers of every hook fired before the bind, and follow promise continuations (#22325)
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-#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>1 parent e87070e commit c6fc938
1 file changed
Lines changed: 534 additions & 39 deletions
0 commit comments