Skip to content

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 into
mainfrom
claude/issue-22257-prebind-read-auth
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22257-prebind-read-auth

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22257
Clause-②: no

What was wrong

On examples/app-showcase, os dev --seed-admin --fresh logged one [SettingsService] Pre-bind READ of namespace 'auth' on every boot. Re-measured on origin/main 79c35d45 (after PR #22295): 1 line, in the boot-diagnostics block.

The reader. A temporary stack capture in the built reportPreBindRead (applied to service-settings/dist/index.js and reverted; the sha256 matched before and after, and the marker count after the revert was 0) named it:

SettingsService.getNamespace from AuthPlugin.bindAuthSettings, then applySettings (auth-plugin.ts:1535 at 79c35d45), from AuthPlugin.ensureAuthSettingsBound, from runBackfill (:1235 / :1244), from the kernel's trigger.

The handler is the app:seeded hook registered in AuthPlugin.start() (:1298), which calls runBackfill('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 before Triggering kernel:ready hook. AppPlugin.start() emits app:seeded when its inline seed lands. On os dev, the auth plugin started first, so its handler ran during Phase 2. SettingsServicePlugin binds the engine in its kernel:ready hook (settings-service-plugin.ts:221), which is later.

The optionalDependencies: ['com.objectstack.service.settings'] edge on AuthPlugin orders only kernel:ready HOOKS. An event fired during Phase 2 is outside it.

The consequence. ensureAuthSettingsBound is memoized, so the auth binding was computed from the manifest defaults. In the debug log, Auth: bound to settings namespace=auth appears at 14:18:11.661, before kernel:ready at 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 policy auto.

Why check:settings-bind-window missed it. The gate walks init() / start() bodies and kernel:ready handlers only (READY_HOOK). It reached this same read through the kernel:ready registration of runBackfill, and classified it declared. The app:seeded registration fires during Phase 2, from another plugin's start(), 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:ready hook. Any trigger before that does nothing: app:seeded during Phase 2, or default-org-created from the bootstrap middleware. The kernel:ready pass still runs afterwards, after the settings bind, and scans every row such a trigger was about. Triggers after kernel:ready behave exactly as before. That includes an over-budget seed that settles in the background (the #2996 path).

  • No change to which settings are read, only to when.
  • No change to the reporter's level or text.
  • No packages/spec change.
  • The comment above ensureAuthSettingsBound in runBackfill was stale: it said "registered in init()". It is corrected.

Boot, before and after

Pre-bind READ lines Auth: bound to settings namespace=auth
79c35d45 (base) 1 (auth) Phase 2, before the kernel:ready trigger
9b7ac6cf (fix; plugin-auth dist rebuilt, backfillArmed grep 3 in dist/index.mjs) 0 after the kernel:ready trigger (16.538 vs 16.381)

In both boots the D6 pass still records adr-0093-membership-backfill once. The other boot-diagnostic lines are unchanged: sys_migration UNIQUE and [sharing-rule]. See the acceptance notes.

Pins

packages/plugins/plugin-auth/src/auth-settings-seeded-boot.pin.test.ts composes:

  • a real ObjectKernel
  • ObjectQL on in-memory SQLite
  • the REAL SettingsServicePlugin
  • the real AuthPlugin, used before the settings plugin (the os serve order)
  • an app plugin in AppPlugin's place: it writes the persisted auth.membership_policy = invite-only row, then emits app:seeded from its start().

The dogfood harness cannot compose this. bootStack registers AppPlugin before AuthPlugin, so app:seeded fires before auth registers its handler.

The main case asserts four things:

  1. The window is real: at app:seeded, the settings engine is unbound.
  2. No Pre-bind READ is reported.
  3. The auth binding's first getNamespace('auth') answers { value: 'invite-only', source: 'global' }.
  4. Every run of the one-time pass gets policy: 'invite-only'.

A control case forces a pre-bind read of auth in the same composition and expects exactly one report.

@objectstack/service-settings is added as a devDependency of plugin-auth. It is aliased to src/ in vitest.config.ts (anchored, like the three existing entries), so KNOWN_UNALIASED_TEST_IMPORTS is unchanged. The lockfile gains only that importer entry.

Control. settings-prebind-read-warning.test.ts passes 9/9 unchanged. It still fires on a forced pre-bind read.

Ablation, via scripts/ablation-replace.mjs from committed d907051b, with an EXIT/INT/TERM restore and the restore proven by blob == HEAD and an empty git diff HEAD. AuthPlugin is imported relatively, so src/ is what runs and no dist rebuild was involved.

  • Leg 1: delete if (!backfillArmed) return backfillChain; (anchor 1 to 0, blob d559d607 to 83e4649d). The main case goes RED at assertion 2: one Pre-bind READ of namespace 'auth'. The control stays green. Restored to d559d607.
  • Leg 2: the same deletion held, plus assertion 2 removed from the test. The main case goes RED at assertion 3: the first auth read 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 on d907051b. The merge after it brought only docs/adr/0096.
  • pnpm --filter @objectstack/plugin-auth run typecheck: exit 0. check:test-typecheck is OK, and the new file is in the tsconfig.test.json program (--listFiles: 1).
  • node scripts/pm/dispatch-gates.mjs --commands (no paths) at b67e95f1 derived 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).
  • Selected verdict lines:
    • check-test-source-alias OK — 73 packages with tests scanned; 60 registered
    • check:workspace-manifest-cycles OK: 80 workspace package(s), 515 workspace: edge(s)
    • check-nul-bytes: OK
    • check:undeclared-dep-imports passed
    • check-adr-0087-registration: 1 non-breaking changeset(s) seen
  • eslint, narrowed to the 3 changed TS files: 0 errors and 0 warnings in --format json. Each file has a non-empty rule set under --print-config. eslint.config.mjs enables no type-aware linting, so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is left to CI.
  • NOT MEASURED: CI's Test Core, Dogfood, Build Core and the type-check lanes. These are CI-owned.

Acceptance notes

  • Gate blind spot (not fixed; no gate added). check:settings-bind-window cannot see two things:

    • a settings read reached through a hook other than kernel:ready that fires during Phase 2 (app:seeded, emitted from AppPlugin.start());
    • a read reached through a data-middleware or callback closure fired by a Phase-2 write.

    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 single the 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 makes AppPlugin declare com.objectstack.auth as an optional dependency, and creates the Default Organization in AuthPlugin.start(). Without this fix, the Phase-2 app:seeded pass would then have a target and would DECIDE the one-time backfill under the manifest-default policy. This fix arms the pass at kernel:ready, so it holds under either landing order. The two diffs touch different regions of auth-plugin.ts: theirs is around :725 and :1168, this one is :1232 to :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 after adr-0093-default-org-owner-bind is recorded. Root cause is NOT MEASURED; it is reported to the seat.

  • Merge state. origin/main was merged at 799eb000. main has since moved 4 commits. None touches plugin-auth or service-settings; plugin-security's strict mode is the nearest, and this composition mounts no SecurityPlugin.


Generated by Claude Code

claude added 3 commits October 8, 2026 14:26
…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>
@github-actions github-actions Bot added the size/m label Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

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 dc4a5c630844f04587e790c21ca807f306239518 → packageMentionDocs.

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
…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>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 15:48
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 15:48
@objectstack-fleet
objectstack-fleet Bot disabled auto-merge October 8, 2026 16:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 0ee2d15 Oct 8, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22257-prebind-read-auth branch October 8, 2026 16:36
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants