Skip to content

Commit c6fc938

Browse files
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

File tree

0 commit comments

Comments
 (0)