Skip to content

feat(core): ObjectKernelConfig.bootPhaseHooks, a declared option to bootstrap without dispatching kernel:ready / kernel:bootstrapped / kernel:listening - #22354

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22272-kernel-boot-phase-hooks-option
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22272-kernel-boot-phase-hooks-option

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22272

Clause-②: yes (widening: a new public option on ObjectKernel)

That line is the claim's (comment 6067012449), copied verbatim.

What

ObjectKernelConfig.bootPhaseHooks?: boolean, default true.

new ObjectKernel({ bootPhaseHooks: false }) runs the rest of bootstrap() unchanged: dependency ordering, every plugin's init(), the core-service fallbacks, every plugin's start() and the system-requirement check. It then returns without dispatching kernel:ready, kernel:bootstrapped or kernel:listening, and the kernel is running. Only an explicit false withholds the hooks. An absent option or undefined keeps today's boot. Runtime forwards the option through its existing kernel config with no change.

A withholding boot logs one warn. It names each withheld hook and how many registered handlers it left uncalled, and the line's meta carries the same counts as withheldHandlers. The boot's completion line says the hooks were withheld.

The option's docblock states three things:

  • What it withholds: the kernel's dispatch of the three hooks, and nothing else.
  • What it guarantees: every service and definition a plugin registers in init() or start(), including a driver connected there. shutdown() is unchanged.
  • What it does not guarantee: that the definitions equal a full boot's. A definition registered in a boot-phase handler is absent. "No handler registers one" is a reading of a given set of plugins, not a property of the option. It withholds the kernel's dispatch, not the hook names.

The PM's mechanism hypotheses, as measured

  • H1 holds. In ObjectKernel.bootstrap() (packages/core/src/kernel.ts at 3599fef12), the order is:

    • the Phase 1 init loop (L442-445);
    • preInjectCoreFallbacks() (L451);
    • the Phase 2 start loop (L453-484), with state = 'running' at L455;
    • validateSystemRequirements() (L487);
    • context.trigger('kernel:ready') (L489), 'kernel:bootstrapped' (L501) and 'kernel:listening' (L511);
    • the completion line (L513).

    No hook fires between init and start, and only the completion line follows the three. git grep over the non-test files in packages/** finds no other caller that triggers any of the three names. hook-dispatch.ts holds only the shared dispatch loop, so the option is one branch after validateSystemRequirements().

  • H2: the option gives the same observable result as the consumer's wrapper, measured with in-repo plugins. A one-off script (not committed) composed ObjectQLPlugin, DefaultDatasourcePlugin (sqlite-wasm, in memory), AppPlugin(examples/app-todo) and a probe plugin that subscribes to all three hooks. It booted that composition three ways: a full boot, cloud's patch reproduced (wrap context.hook and drop the three names), and bootPhaseHooks: false. Results:

    • object definitions from objectql.registry.getAllObjects(): 5 in each mode, with the JSON byte-equal across all three (sha256 prefix e5a58708de9898a4);
    • datasource definitions: ["default"] in all three;
    • the probe fired kernel:ready, kernel:bootstrapped and kernel:listening on the full boot, and nothing in the other two modes;
    • every kernel shut down to stopped.

    That a full boot matches the withheld boot is again a reading of one slate. The two mechanisms differ in one place: the wrapper refuses the registration, while the option skips the dispatch. So under the option a handler for those names is still stored, and counted in the warn. A plugin that called context.trigger on one of the names itself would still run its handlers, but no plugin in this repository does that.

  • H3: the name is bootPhaseHooks?: boolean, default true. It names the mechanism the kernel controls, and it is the card's own spelling: { bootPhaseHooks: false } reads as "no boot-phase hooks". Among the existing options it sits with the positive-sense booleans that default on, gracefulShutdown and rollbackOnFailure. It does not follow skipSystemValidation, because that option's skip* spelling and docblock mark a test escape, and this is a production mode for a repair host. A mode named for the outcome, such as definitionsOnly or bootMode: 'repair', was rejected. It would promise complete definitions, and the kernel cannot keep that promise, because a definition registered in a boot-phase handler is absent.

The card's open question (the seat's ruling in the claim)

This is a separate ObjectKernel option, not a variant of composeForDeclarations, and the cli utility is not touched.

The cli utility cannot share this mechanism as it is written. composeForDeclarations works per host plugin. A context proxy declines kernel:bootstrapped and kernel:listening at registration, and that plugin's start() is suppressed. The base stack's hooks, kernel:ready included, still run on that boot. The option is kernel-wide and keeps every start(). Moving the cli onto it would change which plugins' hooks a migration boot runs.

Tests

packages/core/src/kernel.boot-phase-hooks.test.ts holds 9 tests over one composition. A registry plugin and a declarer register one definition each in init(), in start() and in a kernel:ready handler, with a spy on each boot-phase hook.

  • Under bootPhaseHooks: false:
    • the init() and start() definitions are present and the kernel runs;
    • the kernel:ready definition is absent;
    • no spy fires;
    • a kernel:ready handler that throws and a kernel:bootstrapped handler that never settles do not stop the boot;
    • the warn reports { kernel:ready: 2, kernel:bootstrapped: 1, kernel:listening: 1 }, once;
    • shutdown() runs kernel:shutdown, destroy() and onShutdown() in that order, and reaches stopped under a 2 s guard without calling process.exit. No withheld hook fires during shutdown.
  • Controls (the option absent, true and undefined): all three hooks fire once, in order, all three definitions are present (the kernel:ready one included), and nothing is reported as withheld.

Ablation. The option was made a no-op through scripts/ablation-replace.mjs: the anchor if (this.config.bootPhaseHooks === false) { was replaced with if (this.config.bootPhaseHooks === false && false) {, the anchor count went from 1 to 0, and the blob changed. The result was 5 red and 4 green, in the expected direction.

  • Red: the absent definition, no dispatch, the throw and never-settle case, the withheld report, and shutdown (the spies fired).
  • Green: the init()/start() guarantee pin and the three controls.

The run was repeated at the merged head 4e62f58ea (blob 707ede024adb to 5ed4fdcf902c) with the same 5/4 split. After restore, the blob equals HEAD's 707ede024adb and git diff HEAD is empty. The test imports ./kernel from source, so dist/ is not in the resolution path.

Runs at 4e62f58ea (the branch with origin/main 54c3ce10c merged in, which brings in #22334's signal-listener change to the same file; the textual merge was clean):

  • pnpm --filter @objectstack/core test: 82 files, 2228 tests passed. Before the merge, at 63173d1c6: 80 files, 2208 tests.
  • pnpm --filter @objectstack/core typecheck: green. check:test-typecheck is OK: the new file is in the tsconfig.test.json program and compiles clean, and the ledger is unchanged.
  • A reverse check of the rebuilt .d.ts, run from @objectstack/runtime at 63173d1c6 with a throwaway file that was then deleted:
    • { bootPhaseHooks: false } compiles when typed as ObjectKernelConfig and as RuntimeConfig.kernel;
    • { bootPhaseHooks: 'no' } fails with TS2322;
    • runtime's program reports no other error.

Gates

node scripts/pm/dispatch-gates.mjs --commands was derived at 4e62f58ea with no paths passed. The change set is 3 paths against merge base 54c3ce10c, and the derivation gives 63 commands, the dispatch list's 49 plus 14. Most of the 14 come from the changeset, which now exists. The rest come from the new test file: check:engine-double-contract, check:objectql-double-limit, check:query-options-erasure, check:where-matcher, check:type-check-coverage and check:type-check-debt.

  • All 63 were run at 4e62f58ea, and all 63 exited 0. Exit codes were captured before any pipe.
  • The reconciliation over an exit-coded record printed: ✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3).
  • Some readings:
    • check:kernel-hook-pairs: 4 dispatched kernel:* hooks, each pinned in both kernel.test.ts and lite-kernel.test.ts;
    • check:nul-bytes: OK;
    • check:type-check-debt: 26 raw errors, none above their recorded number;
    • check:dual-build-cjs-loads: 106 require entry points across 66 packages load. An earlier run at 5eb197934 printed PREREQUISITE NOT MET, which is not a pass. It measured on a later tree.
  • Left to CI, as the derivation itself lists: the path-scheduled CI jobs, the four type-check lanes, the 52 artifact-roster families and the 11 wide-population families.

Signal listeners under bootPhaseHooks: false (the domain:engine seat's question 6067560824)

Written into this body by the domain:spec seat 2 at 2026-10-08T21:45Z. The comments before it on this PR are read: the docs-drift bot's, and the contract review PASS 6069627157.

Acceptance notes

  • LiteKernel has no matching option. The claim scopes this card to ObjectKernel, the kernel a hosted runtime boots. No hook name is added, so check:kernel-hook-pairs stays green. Carrier: none.
  • Docs. The "Boot Configuration" table in content/docs/protocol/kernel/lifecycle.mdx lists the kernel options and could gain a row for this one. That file is outside this claim's file surface, and the table makes no claim to be complete, so nothing in it is false now. Carrier: none.
  • A cli comment. A comment in packages/cli/src/utils/schema-migrate.ts says kernel.ts fires kernel:ready "unconditionally". That is still true for that boot, which does not set the option. Noted, not changed.
  • Shutdown. The kernel waits on nothing a withheld hook would have done. A plugin's own destroy() that assumes its boot-phase handler ran is that plugin's concern, and the docblock says so. The in-repo HTTP adapter's close() already returns early when it never listened (packages/plugins/plugin-hono-server/src/adapter.ts).
  • Cross-lane. packages/core belongs to the domain:engine lane, declared on [PM seat] domain:engine — ⏳ vacant #6367 (comment 6067025526). A contract review at CONTRACT_REVIEW_TIER is owed before enqueue. That review belongs to the seat.

Generated by Claude Code

claude added 4 commits October 8, 2026 19:08
…ootstrap without dispatching kernel:ready / kernel:bootstrapped / kernel:listening

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
@github-actions github-actions Bot added size/m 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

This PR changes 1 package(s): @objectstack/core, touching 5 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/knowledge-rag.mdx (via ObjectKernel (symbol, a top-level class))
  • content/docs/kernel/architecture.mdx (via ObjectKernel (symbol, a top-level class))
  • content/docs/kernel/events.mdx (via ObjectKernel (symbol, a top-level class))
  • content/docs/kernel/index.mdx (via ObjectKernel (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via ObjectKernel (symbol, a top-level class))
  • content/docs/plugins/anatomy.mdx (via ObjectKernel (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via ObjectKernel (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via ObjectKernel (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via ObjectKernel (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via ObjectKernel (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 27 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 54c3ce10ce8cf89290b67813c34d1f10dbab6938 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 259716e465df1cbb6c9e3319fa3c68cb2c9af936 — the merge of head 4e62f58ea0f19b334fed827e6295287fac5291da into base 54c3ce10ce8cf89290b67813c34d1f10dbab6938, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 259716e465df1cbb6c9e3319fa3c68cb2c9af936 && git checkout 259716e465df1cbb6c9e3319fa3c68cb2c9af936
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 54c3ce10ce8cf89290b67813c34d1f10dbab6938 4e62f58ea0f19b334fed827e6295287fac5291da && git checkout -B drift-repro 54c3ce10ce8cf89290b67813c34d1f10dbab6938 && git merge --no-ff 4e62f58ea0f19b334fed827e6295287fac5291da

node scripts/docs-audit/affected-docs.mjs --json 54c3ce10ce8cf89290b67813c34d1f10dbab6938

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 54c3ce10ce8cf89290b67813c34d1f10dbab6938 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4e62f58ea0f19b334fed827e6295287fac5291da
Local-runs: none

Inputs: card #22272 (body and all six comments: the triage grade 6059309031 and its amendment 6064871685, the claim 6067012449, the engine seat's reply 6067560824, the os-dev-report 6069467469, the seat review 6069494235); PR #22354 (body, file list, and the net diff against main at merge base 54c3ce10c: 3 files, +342 / -0, head repo equals base repo, 0 governed paths); the check-runs on the head. Read-only: the head was fetched into a private ref and read with git show / git diff; nothing was built, run or re-run.

① Derived judgments

  1. ObjectKernelConfig.bootPhaseHooks?: boolean is a widening of a published surface — right. packages/core/src/index.ts re-exports ./kernel.js, so the field is public on @objectstack/core. It reaches @objectstack/runtime structurally through RuntimeConfig.kernel?: ObjectKernelConfig (runtime.ts:23) and new ObjectKernel(config.kernel) (runtime.ts:78) with no runtime source change, as the body says. Nothing previously accepted is refused: true, undefined and absence all take today's boot.
  2. Only an explicit false withholds — right, and the undefined control is load-bearing. The constructor spreads ...config over the defaults, so a host forwarding bootPhaseHooks: undefined lands undefined in this.config; the branch tests === false, so that spelling is the default boot. The describe.each control pins exactly that spelling.
  3. Placement of the early return — right. At the head the branch sits after validateSystemRequirements() and before the kernel:ready dispatch, inside the try; the three dispatches (head lines 577 / 589 / 599) are followed only by the completion line, so H1 holds on the head as it did at 3599fef12. The catch path (state to stopped, releaseShutdownSignals() from fix(core): a stopped ObjectKernel removes its signal listeners and never exits the process #22334) is reached exactly as before for any failure ahead of the branch. state = 'running' is set at the Phase 2 start, so a withholding boot ends running; the test pins it.
  4. No new hook name, no new dispatch site — right. BOOT_PHASE_HOOKS holds the three literals as array elements, never as the argument of trigger / triggerHook / triggerHookOrThrow, which is the only shape scripts/check-kernel-hook-pairs.mjs reads; the dispatch sites keep their literals. The spec contract (packages/spec/src/contracts/plugin-lifecycle-events.ts) binds the ORDER ready, then bootstrapped, then listening; the option dispatches all three or none, never a subset, so that contract is untouched. No ADR anchor names kernel.ts, and no ADR records that ObjectKernel.bootstrap() dispatches the three unconditionally.
  5. warn, once, with withheldHandlers in the meta — right by the degradation rule. What is withheld is functional (handlers not run), asked for by the host, and named in the line itself; nothing claimed-persisted is lost, so error would be over-applied. The strings carry no tracker number.
  6. The completion line is distinguishable — right. Every matcher on Bootstrap complete in the repo (boot-log-capture, the serve e2e tests, the core: kernel:listening / kernel:bootstrapped / kernel:shutdown 在 LiteKernel 上仍被吞 —— HonoServerPlugin 的 listen() 失败会得到一句「✅ Bootstrap complete」而没有监听端口 #5257 pins in kernel.test.ts / lite-kernel.test.ts) is a substring match, so the suffixed line still reads as the boot line where that is wanted, and the suffix plus the preceding warn say that nothing is listening. The core: kernel:listening / kernel:bootstrapped / kernel:shutdown 在 LiteKernel 上仍被吞 —— HonoServerPlugin 的 listen() 失败会得到一句「✅ Bootstrap complete」而没有监听端口 #5257 invariant (never print it after a kernel:listening handler threw) is about a failed dispatch; a withheld dispatch the host asked for is a different shape and is announced as such.
  7. The docblock's three statements are the accept set the kernel can actually keep — right. It guarantees only what bootstrap() still runs (ordering, every init(), the core fallbacks, every start(), the requirement check, an unchanged shutdown()), and refuses to guarantee definition equality with a full boot. Core's own fallbacks/authored-translation-sync.ts:262 registers a kernel:ready handler, so even a plugin-free composition has withheld work under false; the docblock's "a reading of that set, not a property of this option" covers it, and the warn count shows it. The outcome-named spellings (definitionsOnly, bootMode) were rightly rejected for the same reason.
  8. LiteKernel keeps dispatching the same three (lite-kernel.ts:140-149) and gains no option — right as scoped. The option lives on ObjectKernelConfig, not on a shared config, so no surface advertises what LiteKernel does not deliver. The asymmetry is real and is named in the acceptance notes.
  9. Tests: the two halves the claim asked for, and not phantoms. kernel.boot-phase-hooks.test.ts imports ./kernel from source; tsconfig.test.json includes src/**/*, so check:test-typecheck compiles it (Type Check · debt ledger is green on the head). Pinned: present under the option, absent under the option, present without it, no dispatch, throw / never-settle, the withheld report, the shutdown order under a 2 s guard with process.exit spied, and three controls. The ablation (5 red / 4 green, restored to the HEAD blob) is the report's reading, not re-run here.
  10. Docs. The "Boot Configuration" table in content/docs/protocol/kernel/lifecycle.mdx is hand-written, introduced as "the relevant knobs", and no gate ties it to ObjectKernelConfig (the only script naming skipSystemValidation is run-with-stall-guard.mjs). The consumer-facing text ships in the changeset. Nothing there is false; a row is a follow-up, not a condition.

② Semver level

  • .changeset/22272-kernel-boot-phase-hooks-option.md declares @objectstack/core minor, which matches what the diff publishes: one optional field on an exported interface and a new behaviour behind it, in @objectstack/core alone. @objectstack/runtime changes no source; its RuntimeConfig.kernel widens through the dependency, which is what the core bump carries.
  • The changeset declares Clause-②: yes (widening); the PR body carries Clause-②: yes (widening: a new public option on ObjectKernel), copied from the claim. scripts/pm/clause2-line.mjs reads the arm as the first token inside the parenthesis and tolerates a gloss after it, so both lines resolve to yes / widening; yes takes at least minor, held. Not breaking, so no ADR-0087 disposition marker is owed and check-adr-0087-registration has nothing to judge; Check Changeset is green on the head.
  • Size 342 changed lines, far under the 5,000-line class; no governed path; the head repo is the base repo; the PR is a draft with auto-merge unarmed.

③ Boundary flags

  • Dev open_questions: empty. Nothing to answer.
  • The card's open question (a variant of composeForDeclarations, or a separate option): ruled in the claim 6067012449, followed by the diff, factual basis verified. packages/cli/src/utils/schema-migration-plugins.ts:85-125: composeForDeclarations is per host plugin, overrides start only, declines kernel:bootstrapped / kernel:listening at registration, and the base stack's kernel:ready hooks still run. The kernel-wide option keeps every start() and withholds all three, so the two are different mechanisms; the cli is untouched. Answered.
  • Dev acceptance note, LiteKernel parity: answered (①8). Carrier none is right; the claim scopes the card to ObjectKernel.
  • Dev acceptance note, the docs table: answered (①10).
  • Dev acceptance note, the cli comments saying kernel.ts fires the three "unconditionally" (schema-migrate.ts:527, schema-migration-plugins.ts:93-95): now true by default rather than unconditionally, still true for that boot, which sets no option, and outside the claim's file surface. Answered; a one-word correction rides the next cli PR.
  • The engine seat's question 6067560824 (a repair kernel still installs the signal listeners under the default gracefulShutdown: true; state which you intend): not answered in the PR body or the report; answered here from the diff, and handed back. The signal listeners are installed in the constructor (kernel.ts:301-303) under gracefulShutdown, before bootstrap() runs; the diff touches neither. So bootPhaseHooks is orthogonal to process ownership: a host-owned repair kernel passes gracefulShutdown: false itself (the test fixture does), and what gracefulShutdown: false means is the question [decision] a kernel with gracefulShutdown: false still calls process.exit(1) when its teardown times out, ending every other kernel in a multi-kernel host; the docs say such a kernel stays out of process management #22335 has put to the maintainer. Coupling the two in this PR would have pre-empted that card, so leaving them uncoupled is the right shape. Escalated to the domain:engine seat's own review of the packages/core hunks, which that seat said it will perform when the PR leaves draft; that review is still owed, and this record does not stand in for it.

Check-runs on the head, read at 2026-10-08T21:43Z: 23 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke, all path-scheduled), 0 failure. Still in progress: Lint & Repo Gates and Test Core (all six shards). Of the required set, TypeScript Type Check, Build Core, Dogfood Regression Gate, Temporal Conformance (live PG + MySQL) and Governed Surface Queue Guard are green; Lint & Repo Gates and Test Core are pending. This verdict is on the contract; landing still waits for every check to go green.

Implemented-by: claude/issue-22272-kernel-boot-phase-hooks-option
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 22:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit f6e1d49 Oct 9, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22272-kernel-boot-phase-hooks-option branch October 9, 2026 01:35
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ess when its teardown times out (objectstack-ai#22389)

Fixes objectstack-ai#22335
Clause-②: no

A kernel built with `gracefulShutdown: false` no longer calls
`process.exit(1)` when its teardown overruns `shutdownTimeout`. It logs
the timeout at `error`, marks itself `stopped`, and `shutdown()`
resolves, so the host decides whether the process exits. A kernel with
`gracefulShutdown: true` (the default) keeps the hard `exit(1)`, with
the same log line as before.

This implements the maintainer's ruling of record on objectstack-ai#22335 (comment
6070759640, letter B):

> **B.** `gracefulShutdown: false` means the kernel does not own the
process: when its teardown times out it logs the error, marks itself
`stopped`, and returns to the host, which decides whether the process
exits. `gracefulShutdown: true` (the default; the kernel installed the
signal listeners itself) keeps today's `exit(1)` on a genuine timeout.
The objectstack-ai#5274 pin keeps what it tests, on a `true` fixture; a new pin holds
"`false` does not exit on timeout"; the JSDoc states both meanings of
the option; the changeset names the host that relied on the old exit and
what it must do (exit itself, as it already handles signals). ⛔ Not
taken: A (the docs bent to the code, and one hung environment ends every
environment in the hosted runtime), C (a second public switch for a
split no measured user needs), D (a change to `shutdown()`'s
never-throws contract for every host).

No option, key or export is added, and `shutdown()` still never rejects.

## What changed

- **`packages/core/src/kernel.ts`, `shutdown()`'s genuine-timeout
branch.** The branch is still reached only by the identity-matched
timeout error. It now reads `this.config.gracefulShutdown`, the same
test the constructor uses to install the signal listeners, so a kernel
exits on a timeout exactly when it took the signals itself.
- `true`: the old code, unchanged. It logs `Shutdown timed out — forcing
exit`, destroys the logger and calls `process.exit(1)`.
- `false`: it logs at `error` and falls through to the `finally`. The
log line names the timeout in ms and says that the teardown is still
running in the background, that `gracefulShutdown` is false, that the
process is NOT being exited, and that the host decides.
  - The non-timeout branch is unchanged.
- **`ObjectKernelConfig.gracefulShutdown` JSDoc** now states both
meanings: who installs the signal listeners, and who exits the process
when a teardown times out. It also says that the hung teardown is not
cancelled and keeps holding what it has not released.
- **`packages/core/src/kernel.test.ts`.** The objectstack-ai#5274 timeout pin ("still
logs the timeout and still forces exit(1) when shutdown genuinely times
out") keeps its title and assertions; only its fixture moves to
`gracefulShutdown: true`. Because a `true` kernel listens on the
worker's own process, the pin now removes any signal listener it added
in its `finally`.
- **New `packages/core/src/kernel-shutdown-timeout-ownership.test.ts`**
(4 tests):
- `false` plus a genuine timeout: no exit, `stopped`, `shutdown()`
resolves, exactly one error-level timeout line carrying an `Error`, and
no `forcing exit` line. The hung teardown is then released: it resumes
in the background (`teardown-resumed`, `destroy`), the kernel stays
`stopped`, there is still no exit, and a second `shutdown()` is the
already-stopped no-op.
- R7's shape: two `false` kernels in one process; stopping the hung one
leaves the sibling `running` with no exit, and the sibling still stops
normally.
- Two controls under `false`: a throwing `destroy()` (isolated inside
the teardown), and an error escaping the teardown (the catch's
non-timeout branch). Neither exits, and neither is reported as a
timeout.
- **`.changeset/22335-graceful-shutdown-false-no-exit.md`**:
`@objectstack/core` `minor` with the **BREAKING** banner, under the
launch-window convention, with ADR-0087 disposition `not-required
(no-migration-prescription)`. It names the host that relied on the old
exit (a `gracefulShutdown: false` host: a server framework with its own
signal handlers, or one kernel per environment in one process) and the
remedy: exit the process yourself once `await kernel.shutdown()`
returns, as you already do for signals.

## Before and after (in process, `process.exit` stubbed)

Probe: two kernels of the same setting per row, `shutdownTimeout: 20`.
The first kernel's teardown either hangs (a `kernel:shutdown` subscriber
that never settles) or throws an error that escapes the teardown. The
host stops the first kernel. "Other kernel" is the second kernel's state
when `exit` was called, or after `shutdown()` returned if it was not.

| Row | Before: exit calls (base `16096e8d7b`) | Before: other kernel |
After: exit calls (`030a795001`) | After: other kernel |
|:--|:--|:--|:--|:--|
| `false`, teardown hangs | 1, `exit(1)` | `running` when the process
exits | 0 | `running`, process alive |
| `true`, teardown hangs | 1, `exit(1)` | `running` when the process
exits | 1, `exit(1)` | `running` when the process exits |
| `false`, teardown throws | 0 | `running` | 0 | `running` |

In every row, before and after, the stopped kernel ends `stopped` and
`shutdown()` resolves.

## Measurements behind the dispatch's assumptions

- **M1, the defect: reproduced.** The first row above, at base
`16096e8d7b`: `exit(1)` fires while the other `false` kernel is
`running`.
- **M2, who relied on the exit.**
- In this repo, no production code builds a kernel with
`gracefulShutdown: false`. `git grep -E "gracefulShutdown:\s*false"
origin/main -- 'packages/**'` at `345d3f3d86` finds 49 files, all tests,
and 0 non-test lines.
- No test except the objectstack-ai#5274 pin can reach the timeout branch. The only
fixtures with a short or explicit `shutdownTimeout` are that pin (20 ms,
now on `true`) and the objectstack-ai#10604 guard pin (60 s, fake timers, its teardown
completes).
- Every production kernel in the repo takes the default (`true`), so its
behaviour is unchanged:
- CLI `serve` (`packages/cli/src/commands/serve.ts:3173`, `new Runtime({
kernel: { logger } })`);
- every one-shot CLI command through `bootSchemaStack`
(`packages/cli/src/utils/schema-migrate.ts:492`, `new Runtime({ cluster:
false })`), which still calls `stack.shutdown()` and then exits;
    - `packages/objectql/src/kernel-factory.ts:35`;
    - `packages/verify/src/harness.ts:504`.
  - No CLI path can now hang where it used to exit.
- The one measured producer of `false` is objectstack-ai/cloud's hosted
runtime (`packages/objectos-runtime/src/artifact-kernel-factory.ts`, per
the ruling's premise read). That path is not in this repo, and this
session has no read access to objectstack-ai/cloud. **NOT MEASURED: what
that host does after `shutdown()` returns.** The changeset names its
shape and remedy.
- **M3, the `true` path is unchanged.** Same branch body, same log line
(`Shutdown timed out — forcing exit`), same `logger.destroy()` and then
`process.exit(1)`. `handleShutdownSignal` is untouched. The objectstack-ai#5274 pin on
a `true` fixture is green, and ablation leg B below shows it depends on
this branch.
- **M4, the abandoned teardown.** After a `false` timeout,
`performShutdown()` keeps running and the kernel does not act on it
again:
- It writes no state; `state` is assigned only in `shutdown()` and
`bootstrap()`.
  - It releases no listeners; that happens only in those two methods.
- `raceWithTimeout`'s `Promise.race` keeps a handler on the operation,
so a later rejection is not unhandled.
- The kernel's own logger, already destroyed in `finally`, writes later
lines to stdout and stderr only (`fileStream` is cleared).
- The first new test pins this: release the hung teardown after
`shutdown()` resolved, and the kernel stays `stopped` with no exit.
- The log line and the JSDoc both say that the hung teardown keeps
running and that the host owns what follows.

## Tests (at `77e21f89c2`; the one commit after the code commit
`030a795001` adds only the changeset)

- `@objectstack/core`, `pnpm --filter @objectstack/core test`: 82 files,
2223 tests passed. `test:repo`: 3 files, 48 passed.
- `pnpm --filter @objectstack/core typecheck`: exit 0.
`check:test-typecheck` is OK with the debt ledger unchanged at 4. `tsc
-p tsconfig.test.json --listFiles` compiles both touched test files.
- `@objectstack/runtime` (its dependency closure built first, 32
projects): `vitest run --project local` gives 341 files, 4787 passed and
19 skipped. `--project repo` gives 3 files, 751 passed.
- **Reverse verification (ablation)** on committed `030a795001`. Each
leg ran through `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob
changed on disk), then restored with `git checkout HEAD`; the restore
was proven by the blob matching HEAD (`0bf80b6dc7ce`) and an empty `git
diff HEAD`. The tests import `./kernel` from source, so no `dist/` is
involved.
- **Leg A**: the new condition forced to `if (true)`. Both `false` pins
go red (`expected [ [ 1 ] ] to deeply equal []`), and the two controls
and the objectstack-ai#5274 pins stay green.
- **Leg B**: the condition forced to `if (false)`. Only the objectstack-ai#5274
timeout pin goes red (`expected [ Array(1) ] to include 'Shutdown timed
out — forcing exit'`), and the `false` pins stay green.
- **Lint, a declared narrowing.**
- eslint `--no-inline-config --format json` on the three touched TS
files: 3 files, 0 errors, 0 warnings. `--print-config` resolves a config
for each, so they are inside the linted population.
- This narrowing cannot change any verdict on an untouched file:
`eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project` and no `projectService`, which it also states in
prose).
  - The full `pnpm lint` belongs to CI.
- **Gates.**
- `node scripts/pm/dispatch-gates.mjs --commands` at `77e21f89c2`
derived 63 commands, and each was run with its exit captured.
- All 63 exited 0. `pnpm check:dual-build-cjs-loads` first exited 3,
PREREQUISITE NOT MET, because it reads every package's `dist/`. It was
re-run after a whole-tree `turbo run build` (72 tasks) at the same head:
107 published require entry points across 66 packages load.
  - `--ran` reconciliation: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN.

## Serial check with objectstack-ai#22354

Re-fetched just before opening: `origin/main` is `117d34de3f`, and
objectstack-ai#22354 has not landed. Nothing in `packages/core` moved since base
`16096e8d7b`, so this branch is not merged with main. Both merges are
clean:

- `git merge-tree --write-tree origin/main HEAD` exits 0 (tree
`4c796f278a`).
- `git merge-tree --write-tree` of objectstack-ai#22354's head (`4e62f58ea0`) with
this branch exits 0 (tree `8dead452b0`), and the merged `kernel.ts`
carries both changes.

This branch does not touch `bootstrap()` or `bootPhaseHooks`.

## Acceptance notes

- `content/docs/protocol/kernel/lifecycle.mdx` is not edited. Its
sentence (a `false` kernel "stays out of the process-management
business") was false only on the timeout path, and is now true there
too.
- `content/docs/kernel/events.mdx` says `process.exit(1)` "is reserved
for a genuine `shutdownTimeout` overrun". It is still true as an
exclusivity statement, but a reader could take it to mean every overrun
exits. Not edited (not in this card's file surface). Carrier: none.
- The pending note `.changeset/22286-kernel-signal-listeners.md` listed
under "Unchanged" that "A genuine shutdown timeout still calls
`process.exit(1)`". Both notes ship in the same pre-mode release, so
patch round 1 qualifies that sentence (see below).
- `new ObjectKernel({ gracefulShutdown: undefined })`, an explicit
`undefined`, overrides the spread default. Such a kernel installs no
listeners and, after this PR, also does not exit on a timeout. That is
consistent by construction, because both halves read one predicate. No
producer in this repo passes a possibly-undefined value.
- `shutdown()` resolves the same way whether the teardown finished or
timed out; the timeout reaches the host only through the `error` log
line. A host that wants a non-zero exit status after a hung teardown has
no return-value signal, since option D was not taken.
- Read, not measured: if a logger `file` is configured and a plugin logs
through a child logger after a `false` timeout, that write meets the
file stream the parent closed. The logger's own `error` handler disables
file logging with one notice; it is not fatal. Under `true` this was
unreachable, because the process exited.

## Patch round 1: objectstack-ai#22286's pending note qualified

Commit `c6c4351a82` changes one sentence in
`.changeset/22286-kernel-signal-listeners.md`, in its "Unchanged"
bullet, and nothing else in that file. It makes no code or test change.

- Before: "A genuine shutdown timeout still calls `process.exit(1)`."
- After: "With `gracefulShutdown: true`, a genuine shutdown timeout
still calls `process.exit(1)`; a `gracefulShutdown: false` kernel no
longer exits (objectstack-ai#22335)."

**Why.** `.changeset/pre.json` on `main` is in pre mode (`mode: pre`,
tag `next`) and has consumed no changesets yet. So that note and this
PR's `.changeset/22335-graceful-shutdown-false-no-exit.md` ship in the
same release, and the unqualified sentence would be false there for a
`gracefulShutdown: false` kernel. This is a deliberate correction of
another card's pending note. `Check Changeset` stays red on it by design
(the foreign-correction rule), and the seat's same-head contract review
is the written confirmation.

**Gates at `c6c4351a82`.**

- `dispatch-gates --commands` derived the same 63 commands as before. It
warned that the tree is behind `origin/main` `c8c803c293`, where two
gate inputs changed
(`scripts/migrate/overlay-views-to-sys-view-definition.md` and
`scripts/platform-object-tenancy-census.json`). Nothing in
`packages/core`, the two changesets or `.changeset/pre.json` moved on
`main`, and objectstack-ai#22354 has not landed, so this branch is not merged with
main.
- The whole tree was rebuilt first (`turbo run build`, 72 of 72 tasks
cached), then each command ran with its exit captured. 62 exited 0.
- `node scripts/check-empty-changeset.mjs --base origin/main` exited 1,
as expected. It reports ".changeset/22286-kernel-signal-listeners.md:
present on the merge base and CHANGED by this PR", the
deliberate-correction class above.
- `--ran` reconciliation: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_

---------

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/m tests tooling

Projects

None yet

2 participants