Skip to content

fix(cli): os migrate plan / apply run no app onEnable and no post-declaration host hooks - #21138

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21054-plan-no-app-hooks
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21054-plan-no-app-hooks

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21054
Clause-②: yes (widening)

What was wrong

os migrate plan and os migrate apply boot the host's stack to read what it declares. That boot also ran host code that has nothing to do with declarations:

  • the config's onEnable, which runs from the AppPlugin the migrate composition builds out of objectstack.config.ts. That AppPlugin is not wrapped by composeForDeclarations.
  • every kernel:bootstrapped / kernel:listening hook that a host plugin registers from init(). composeForDeclarations suppressed start() and nothing else.

examples/app-crm's onEnable hooks kernel:bootstrapped and reads sys_position / sys_permission_set. The plan's composition never declares those tables. The write guard from the earlier write-suppression work refuses writes and lets hooks run, so it cannot stop a read. Every plan therefore printed 6 DATABASE_ERROR lines on stderr and 6 position binding lookup failed warnings, on a migrated file and on an absent one.

The fix: one point per door, for every app

Host code enters a declaration boot through exactly two doors, and each is closed where it enters:

  1. The config's onEnable. AppPlugin gets a skipOnEnable option, a sibling of the existing skipSeedData, which serves the same commands. With it set, start() does not run onEnable. It logs runtime.onEnable NOT executed … and reports the skip through onEnableWithheld. The migrate composition sets the option on the app it builds.
    • The option lives in the executor (packages/runtime/src/app-plugin.ts) and not in a stripped copy of the bundle. The executor is what resolves which object carries the hook (bundle.default before the bundle itself). A copy would re-state that rule at the CLI call site. The boot would then also log "No runtime.onEnable function found" about an app that has one.
    • A compiled artifact cannot carry onEnable at all: it is JSON, and its runtime module contributes functions only. A host plugin that is itself an AppPlugin is already wrapped, so its whole start() is suppressed.
  2. A host plugin's init(). composeForDeclarations now forwards init with a context whose hook() does not register kernel:bootstrapped or kernel:listening. A host that keeps that context and registers later is declined too.
    • The two phases come from the kernel contract (IPluginLifecycleEvents). kernel:bootstrapped is for "reconcile/backfill work that consumes" data. kernel:listening comes after every plugin "has had a chance to register routes / services / middleware during kernel:ready". Both say that registration is over.
    • kernel:ready is deliberately kept. The contract puts late registration there, and a host that provisions its tables from a kernel:ready hook is a measured shape whose tables the plan must see. The write guard still refuses row writes on kernel:ready.
    • kernel:shutdown hooks, data hooks and custom events register as before.

This repo's own plugins (the data stack, PlatformObjectsPlugin, the guard, and extraPlugins) are not host code and are untouched. The plan still prints the value-shape gate announcement that the engine makes from its own kernel:bootstrapped hook. No in-repo plugin registers a post-declaration hook from init(): all seven sites are in start().

The plan's notes, and composition.notes in --json, carry one line naming what was not run. A host with nothing withheld gets no line.

Stop clause (does the plan need an app hook for its declarations?) No. On app-crm, the table list, the pending DDL, the drift and --json (all but notes) are identical before and after; see below.

The earlier design, and how this changes it

The write-suppression card chose to refuse writes at the driver over neutralising init()-registered hooks. One reason was that a log-only hook should keep running on the plan path. Triage's direction on this card (comment 5924795251) sets the boundary more narrowly: the declaration boot does not fire app onEnable / kernel:bootstrapped hooks. The guard stays the write guarantee on every phase. Only the two post-declaration phases are now withheld for host code. The existing pins that asserted host log-only hooks run on those two phases were inverted in place, and their writers moved to kernel:ready where the case was about the guard rather than the phase. A new pin keeps the guard's phase-agnostic property: a writer the composition does not wrap is refused on all three phases and in start().

Release grading

@objectstack/runtime takes minor, and this PR declares Clause-②: yes (widening). AppPlugin, exported from the package root, gains the optional constructor option skipOnEnable (default false) and the read-only getter onEnableWithheld. That is an additive widening of a published surface, which takes at least minor. @objectstack/cli stays patch. This was re-graded from patch / Clause-②: no after the contract review record 5928867906, in the changeset-only commit afc44ba5bf.

Measured on examples/app-crm

node packages/cli/bin/run.js migrate … from examples/app-crm, with no dist/ artifact. Base is origin/main 9c8b65aa23, built. Fix is af9ac5ded3, with runtime and cli rebuilt.

run base: DATABASE_ERROR (stderr) base: position binding lookup failed base: onEnable executed fix: DATABASE_ERROR fix: lookup failed fix: onEnable executed
plan on an absent file 6 6 yes 0 0 no (withheld, logged)
plan --json, absent file 6 6 yes 0 0 no
apply --yes 6 6 yes 0 0 no
plan on the migrated file 6 6 yes 0 0 no
plan --json, migrated file 6 6 yes 0 0 no
  • On the base, stderr carries those 6 errors plus 2 "Paged read … NOT deterministic" warns from the same hook: 8 lines. On the fix, stderr is empty on every run.
  • The plan output does not change. The non-log stdout differs by exactly one added notes line (diff shows 1 line added and 0 removed, for plan absent, plan migrated and apply). --json is identical except composition.notes (2 to 3 entries): pending 15/15 (absent) and 0/0 (migrated), total 0, managedTables 15. Examined 15 managed table(s) holds on both. The absent file is not created.

Tests

  • @objectstack/runtime src/app-plugin.test.ts: the skipOnEnable pins. The hook is withheld, logged and reported, including when it sits on bundle.default. A bundle with no onEnable reports nothing withheld.
  • @objectstack/cli unit, schema-migration-plugins.test.ts:
    • the init() context declines the two phases and forwards kernel:ready, kernel:shutdown, data hooks and every other member;
    • the composed app carries skipOnEnable, its onEnable does not run, and the lifecycle names it.
  • @objectstack/cli integration, schema-migration-plugins.declaration-boot-write-guard.test.ts, using a real ObjectKernel:
    • the positive control fires all three phases;
    • the fix fires only kernel:ready for host code, keeps the teardown, and leaves an unwrapped platform plugin on all three phases in the same boot;
    • a host that registers later from kernel:ready is declined;
    • the existing write-guard pins were updated as described above.
  • @objectstack/cli integration, schema-migrate.host-composition.integration.test.ts, new block for this card. It uses an app-crm-shaped fixture: a stack with one object, a named onEnable that hooks kernel:bootstrapped and reads the two undeclared tables, and a host plugin with a reading init()-registered kernel:bootstrapped hook.
    • POSITIVE CONTROL: the same code composed as serve composes it prints the lines.
    • CONTROL: apply's confirmed DDL flush still creates the app's table, and the coverage pass still examines it.
    • The plan on the migrated file prints 0 DATABASE_ERROR, runs neither hook, and still runs the host's kernel:ready hook.
    • The plan on an absent file prints 0 DATABASE_ERROR and leaves no file behind.

Runs:

  • At 3781713631:
    • runtime vitest run --project local: 297 files, 4252 passed, 5 skipped.
    • cli --project unit: 240 files, 3422 passed.
    • cli --project integration over the 11 migrate-related files: 59 passed.
  • After merging origin/main:
    • at 5bd79b1b3c: runtime app-plugin.test.ts 35/35; cli unit file 32/32; write-guard, host-composition and plan.deferred-reads 36/36 (integration); typecheck (including check:test-typecheck) green for runtime and cli;
    • at 6d4ef7c9aa: host-composition 14/14 and cli typecheck green, after the test-only fix that check:test-source-alias asked for;
    • the head dc1c40ec39 differs from 6d4ef7c9aa by one comment line.

Gates, at dc1c40ec39.

  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 64 commands. All 64 ran with their exit codes recorded before any pipe, and all 64 exited 0. --ran reports "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN".
  • check:dual-build-cjs-loads and check:i18n-coverage measured, after the nine packages they read were built.
  • check:test-source-alias turned red on the first pass: a dynamic import('@objectstack/runtime') sat inside a test body. Moving it to module top made it green.

Gates, at afc44ba5bf (changeset-only commit). The diff from dc1c40ec39 is the one changeset file, so the code families keep their dc1c40ec39 results.

  • The 19 families whose derivation names the changeset path, or that declare a whole-tree population, ran again: 19 of 19 exited 0.
  • check-changeset-no-major in event mode with this body: the level axis is green ("@objectstack/runtime: minor … the declared widening is accounted for"). The control, the same body against dc1c40ec39 where runtime was patch, exits 1.
  • --ran over the fresh 19 plus the 45 carried from dc1c40ec39: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN".

Lint, a proven narrowing.

  • Command: eslint --no-inline-config --format json over the 7 touched TypeScript files.
  • Result: 7 files, 0 errors, 0 warnings.
  • --print-config resolves a config for all 7, with 5 to 6 rules and no parserOptions.project.
  • The repo's eslint.config.mjs enables no type-aware linting, so this diff cannot move the verdict on any untouched file. The full pnpm lint is left to CI.

Ablations. The fix was committed first. Each run went through scripts/ablation-replace.mjs: the anchor moved 1 to 0, the blob changed, and the restore brought the blob back equal to HEAD with an empty git diff HEAD.

Acceptance notes

  • The pin uses an app-crm-shaped fixture, not examples/app-crm itself. A cli test that reads another package's tree is a cross-package test input. Declaring it would mean editing scripts/cross-package-test-inputs.mjs and turbo.json, which are outside this card's file surface. The real app-crm is measured by the CLI runs in the table above.
  • Residue, stated in the module header. A host that registers a hook without the context its init() received is outside the composition's reach: through getKernel(), or from a service factory, which the kernel calls with its own context. Its writes still meet the guard.
  • Residue: declarations on a post-declaration phase. A host that declares objects from a kernel:bootstrapped / kernel:listening hook would lose them from the plan. The contract says registration is over by then, and no in-repo plugin does it.
  • examples/** and driver-sql are untouched. #20821 is not reopened here; its deferred-DDL demotion is unchanged.

Generated by Claude Code

claude added 6 commits October 1, 2026 07:34
…ration hooks

os migrate plan / apply compose host code for what it declares. The
config's onEnable is withheld by the AppPlugin the composition builds
(skipOnEnable), and a host plugin's init() gets a context that does not
register kernel:bootstrapped / kernel:listening hooks. The plan's notes
name what was withheld.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…hook

Runtime: AppPlugin skipOnEnable withholds onEnable wherever it resolves.
CLI unit: composeForDeclarations' init context declines the two
post-declaration phases; the composed app carries skipOnEnable.
CLI integration: the write-guard pins move host hooks to kernel:ready and
keep the guard's phase-agnostic property on an unwrapped writer; an
app-crm-shaped fixture prints zero DATABASE_ERROR on a migrated and on
an absent file, with a served-composition positive control and the
apply flush/coverage control.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…e 21054 pin

check:test-source-alias: a dynamic import of an unaliased dependency inside a
test body pays its first transform inside a clocked window.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/runtime, touching 17 documentable anchor(s).

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

  • content/docs/kernel/services-checklist.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/capabilities.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/plugins/index.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/plugin-spec.mdx (via AppPlugin (symbol, a top-level class))

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

  • content/docs/releases/v15.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via AppPlugin (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
  • 6 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 — 42 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 665cab338f3f8c78c74e773a10434ed4ea2d12c6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fb98d75bc4ecc8e33b9ff36c48912ec239472f8a — the merge of head afc44ba5bf59bd98f4a78050e48b751cb5f03835 into base 665cab338f3f8c78c74e773a10434ed4ea2d12c6, 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 fb98d75bc4ecc8e33b9ff36c48912ec239472f8a && git checkout fb98d75bc4ecc8e33b9ff36c48912ec239472f8a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 665cab338f3f8c78c74e773a10434ed4ea2d12c6 afc44ba5bf59bd98f4a78050e48b751cb5f03835 && git checkout -B drift-repro 665cab338f3f8c78c74e773a10434ed4ea2d12c6 && git merge --no-ff afc44ba5bf59bd98f4a78050e48b751cb5f03835

node scripts/docs-audit/affected-docs.mjs --json 665cab338f3f8c78c74e773a10434ed4ea2d12c6

⚠️ 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 665cab338f3f8c78c74e773a10434ed4ea2d12c6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dc1c40ec390c5d9ec795ca2fd2e1bec1b556819b
Local-runs: none

This is the record of record for PR #21138 at dc1c40ec39, card #21054 (os migrate plan on examples/app-crm runs the app's onEnable, which reads sys_position / sys_permission_set; 6 DATABASE_ERROR and 6 WARN lines per plan), under triage's first grade 5924795251 (the plan's declaration boot reads declarations and does not fire runtime.onEnable or kernel:bootstrapped app hooks, fixed for every app at one point, not by teaching app-crm's hook to guard its reads; if the plan needs a hook for its declarations the claim stops and reports which one; pin: the plan on app-crm prints no DATABASE_ERROR lines), the seat's claim 5926696048 (file surface: schema-migrate.ts, schema-migration-plugins.ts, packages/runtime/src/app-plugin.ts only if the onEnable executor itself must read a declaration-boot signal, pins beside the two existing declaration-boot suites, one changeset, examples/** read-only, stop clause) and the os-dev-report 5928545144 (done, no open questions, six deviations).

Inputs, and nothing else: card #21054's body and its three comments; #13332 (its body, triage 5478809397, the claim 5486361491 / 5486471695 that picked (b) over (a) partly so log-only hooks keep running on a plan, the report 5487193470, the PM accept 5488945314 and the at-tier verdict 5489067604), #13028, #14126 (its constraint "do not neutralise hooks by phase"), #20821 (closed by PR #21093); PR #21116's body and its record 5927277927 (isShippedFlowName made public, Clause-②: yes (widening), @objectstack/metadata-protocol at minor), PRs #20817 and #20853; PR #21138's body, its one comment (Docs Drift Check), its file list (8 files, +908 / −60; the git stat of the net diff agrees file for file) and the net diff git diff a11faeecb34eb14800d64769cb86a8e1140988f1 refs/review/pr-21138; six commits (af9ac5ded3 the fix, 744234f22d the pins, 3781713631 the changeset, 5bd79b1b3c the merge of origin/main, 6d4ef7c9aa the test-only alias fix, dc1c40ec39 one comment line); source at the head and at the merge-base read through git show / git grep only: app-plugin.ts, schema-migration-plugins.ts, schema-migrate.ts, commands/migrate/plan.ts and apply.ts, core/src/kernel.ts, core/src/lite-kernel.ts, core/src/types.ts, spec/src/contracts/plugin-lifecycle-events.ts, runtime/src/index.ts, runtime/package.json, cli/src/index.ts, cli/package.json, commands/serve.ts, runtime/src/standalone-stack.ts, plugin-dev/src/dev-plugin.ts, verify/src/harness.ts, the seven in-repo hook sites, scripts/cross-package-test-inputs.mjs, scripts/check-cross-package-test-inputs.mjs, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, the WHICH LEVEL prose in .github/workflows/pr-automation.yml, the changesets that added AppPlugin's two sibling options (33a5ff499e, 317132495b), the new changeset and the six commits' trailers; the head's check-runs, polled in the background until they converged and collapsed latest-per-name; origin/main's tip check-runs.

Check-runs on dc1c40ec39, read after convergence and collapsed latest-per-name (a background poll of the commit's check-runs, no local run; every run reports this head): 34 runs, 31 success, 3 skipped (Build Docs and Console Pin Gate path-filtered, Packed-tarball smoke (opt-in) opt-in), 0 failure, 0 cancelled. Every required context is success: Lint & Repo Gates (the check:* family over this diff, check:changeset-no-major, check:adr-0087-registration, check:cross-package-test-inputs and check:test-source-alias among them), TypeScript Type Check and its four sub-jobs (source gates, consumer gates, debt ledger, workspace), Test Core and all six shards (the runtime and cli projects, the four changed test files among them), Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check Changeset, Check PR Size, Check Documentation Links, The card this PR closes must claim this branch, Part-of PR must not also close its card, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Flag docs affected by code changes, filter and Auto Label are success. No run is red, so there is no red to compare against origin/main; for the record, origin/main's tip c6954d6d09 has 36 latest-per-name runs with no red either. The dev's report read 31 runs with 18 in progress at report time; the converged picture is the one above.

Mergeability: git merge-tree --write-tree origin/main refs/review/pr-21138 is clean, tree 02102aef78, exit 0, with origin/main at c6954d6d09, fifteen commits past the merge-base a11faeecb3. None of the fifteen touches any of the eight paths. The merge 5bd79b1b3c has parents 3781713631 and a11faeecb3; the commits it brought in (9c8b65aa23..a11faeecb3) touch none of the eight paths either, and pnpm-lock.yaml did not move.

① Derived judgments

(a) The two doors are closed where each enters, for every app, by phase and not by plugin name; the write guard is unchanged in force; the declined set is exactly the claim's; the stop clause's evidence is true at the head — RIGHT.

Door 1, the config's onEnable.

  • The executor is the right single point. AppPlugin.start() resolves the hook owner at app-plugin.ts:1045-1052 (head): stackBundle = this.bundle.default || this.bundle, then runtime is stackBundle when it carries onEnable and this.bundle otherwise. The new branch at :1054-1061 reads this.skipOnEnable after that same resolution, sets onEnableWithheldFlag, and logs runtime.onEnable NOT executed … at info on the same ctx.logger and at the same level the base's Executing runtime.onEnable line used (:1063); the else if that follows is the base's branch verbatim. A bundle with no onEnable falls through to the base's debug line No runtime.onEnable function found (:1087) with the flag still false, which is the pinned "names only what was really there" property. The dev's objection to a stripped copy is true of the source: a copy at the CLI would restate the bundle.default-before-bundle rule and would make the executor log that no hook was found about an app that has one.
  • The composition sets the option at exactly one site, schema-migration-plugins.ts:1313-1321: new AppPlugin(config, undefined, { skipSeedData: opts.skipSeedData ?? false, skipOnEnable: true }) followed by lifecycle.trackApp(app), inside the unchanged predicate configHasMetadata && !appAlready. It is the only AppPlugin construction in that module.
  • Every other construction path at the head passes no skipOnEnable and takes the default false (:275): serve.ts:3411-3415 (devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {}), serve.ts:4325 (new AppPlugin(pluginToLoad)), standalone-stack.ts:795 (skipSeedData and the registrar; the bundle there is the compiled artifact, which is JSON, and mergeRuntimeModule merges functions only, as app-plugin.ts:1285-1289 states), plugin-dev/src/dev-plugin.ts:569 (new AppPlugin(this.options.stack)), verify/src/harness.ts:489 (new AppPlugin(config)). git grep skipOnEnable over packages at the head finds the option in app-plugin.ts, the composition, and the three test files, nowhere else. os start / os serve / os dev never reach buildSchemaMigrationPlugins, which only bootSchemaStack calls, and only with composeHostStack: true (schema-migrate.ts:350-351). So no served composition changes behaviour.
  • A host-supplied AppPlugin in config.plugins is counted by isAppPluginLike (no second app is composed) and is wrapped by composeForDeclarations, whose start replacement suppresses its whole start() exactly as at the base; AppPlugin.init (:355) registers no hook (its one ctx.hook('kernel:ready', …) at :1216 sits in start, :672), so forwarding its init through the declaration context changes nothing for it, and the lifecycle note rightly does not name it (nothing newly withheld).

Door 2, a host plugin's init().

  • composeForDeclarations(plugin, lifecycle?) (:431-460) now handles init in the same get trap that replaces start: it returns a wrapper that Reflect.applys the target's own init with declarationContext(ctx, label, lifecycle) in place of the kernel's context and forwards the remaining arguments; every other member is forwarded as before, functions bound to the target, constructor left alone. declarationContext (:371-392) is a Proxy over the kernel context whose hook(name, …rest) declines when POST_DECLARATION_PHASES.includes(name) (recording owner|phase into the lifecycle when one was passed) and otherwise forwards to the target's hook with the target as receiver; every other member is forwarded, functions bound to the target. The cut is by phase name against a two-entry constant, ['kernel:bootstrapped', 'kernel:listening'] as const (:260), applied to every config.plugins entry (:1298) and to nothing else. Structural, by phase, not by plugin name. Platform plugins, PlatformObjectsPlugin, the guard and extraPlugins never pass through it, which is why the engine's own kernel:bootstrapped announcement still prints.
  • kernel:ready, kernel:shutdown, data hooks and custom events register as before: the unit pin (schema-migration-plugins.test.ts:129-162, the toEqual at :151) asserts the forwarded list is exactly ['kernel:ready', 'kernel:shutdown', 'data:beforeInsert'] out of five registrations, and the kernel-level pin asserts the kernel:shutdown handler ran on kernel.shutdown(). IPluginContext.hook returns void (core/src/types.ts:71-74), so a declined call returning undefined loses nothing a host could have used.
  • The write guard is unchanged in force. At the merge-base DeclarationBootWriteGuard starts at :480 and createDeclarationBootWriteGuard at :564, running to SchemaMigrationComposition at :988; the diff's hunks against the base sit at :98-108, :175-180, :211-227, :235-252, :1028-1033, :1070-1075, :1085-1091, :1100-1106 and :1154-1160, none inside :480-987. The guard is still composed first (plugins = [writeGuard.plugin], :1283) and still disarmed at the same point of bootSchemaStack (schema-migrate.ts:378), with the lifecycle line pushed right after it (:382-383). The new unwrapped-writer pin (below) measures the guard refusing four writes across start() and all three phases.
  • The declined set is exactly kernel:bootstrapped and kernel:listening. Triage's grade names runtime.onEnable and kernel:bootstrapped; the claim's surface names runtime.onEnable and kernel:bootstrapped / kernel:listening. The set equals the claim's and is one phase wider than the grade's literal, kernel:listening, which the claim named and which the contract (plugin-lifecycle-events.ts:72-78) defines as work "strictly after every other plugin has had a chance to register routes / services / middleware during kernel:ready". kernel:ready, where the same contract leaves late registration and where The composed-host-stack plan sees 8 of ~80 control-plane tables: objectstack#12952's start()-registration residue is the dominant case, not a zero-instance one #13028's measured provisioning shape lives, is not cut; kernel:shutdown is not cut. Not wider. The kernel fires exactly these three post-start phases (kernel.ts:489, :501, :511; lite-kernel.ts:146, :149), so the constant covers every post-declaration phase that exists at the head.
  • Stop clause. git grep at the head for hook('kernel:bootstrapped' / hook('kernel:listening' over non-test packages/*/src finds exactly seven sites, each inside start: mcp/src/plugin.ts:534 (start :287), objectql/src/plugin.ts:657 (start :556), organizations/src/organizations-plugin.ts:277 (start :244), plugin-hono-server/src/hono-plugin.ts:705 (start :505), plugin-pinyin-search/src/pinyin-search-plugin.ts:85 (start :62), plugin-sharing/src/sharing-plugin.ts:990 (start :615), service-automation/src/plugin.ts:1221 (start :722); no other method is declared between each start and its site. A broad grep for the two phase strings over the same population finds no other registration spelling, only the kernel's triggers, the constant, and prose. A start() registration was already suppressed by the base's composeForDeclarations when such a plugin is composed as host code, so no in-repo plugin loses a declaration here. True at the head; the stop clause did not fire, and the dev's "the table list, the pending DDL, the drift and --json (all but notes) are identical" is the reported measurement of the same fact.

(b) The #13332 reversal: every inversion is compelled by the grade, the three writer moves keep their pins non-vacuous, and the guard's phase-agnostic property is pinned by a writer the composition does not wrap — RIGHT, with the overridden constraint named.

(c) The pins red without the fix as reported, the positive controls discriminate, and the app-crm-shaped fixture is an acceptable stand-in — RIGHT.

  • Runtime app-plugin.test.ts (:109-160): withheld / logged / reported; bundle.default resolution; a bundle with no onEnable reports nothing withheld and still logs the base's debug line; the base onEnable case gains onEnableWithheld === false (:103). Read against A3 (the executor branch neutralised): the two withhold cases red, the no-onEnable case green either way — the reported 2/35.
  • CLI unit (schema-migration-plugins.test.ts:129-175, :288-325): the declaration context's forwarded list, withheldHooks and the describe text; the quiet case (registered empty, describe null); the composition case loads a temp objectstack.config.ts whose named onEnable sets a global flag, drives the composed app's start() over a minimal context, and asserts the flag is undefined, onEnableWithheld, lifecycle.withheldOnEnable and the describe text. A1 reds the first two, A2 and A3 red the composition case — the reported 2/32 and 1/32.
  • Write-guard file on a real ObjectKernel (:997-1093): POSITIVE CONTROL (an unwrapped reading host fires all three phases); THE FIX (the wrapped host fires only kernel:ready, the unwrapped platform-probe on the same boot fires all three, withheldHooks is the two expected rows, the guard stays quiet, the kernel:shutdown handler ran); the deferred registrar (a host that keeps its init() context and registers kernel:bootstrapped from a kernel:ready handler is declined and recorded). A1's reported 9/34 is accountable from source: DEFECT, FIX and the embedder control, the two lifecycle cases, the host-composition FIX and R1, and the two plan cases.
  • Host-composition [finding] os migrate plan on examples/app-crm runs the app's onEnable hook, which reads sys_position / sys_permission_set the plan never declares: 6 DATABASE_ERROR + 6 WARN lines on every plan #21054 block (:836-1025): the fixture is a temp project with one object, a host plugin whose init() registers a kernel:ready hook and a reading kernel:bootstrapped hook, and a named onEnable beside the default export that hooks kernel:bootstrapped and reads sys_position / sys_permission_set through ctx.ql — the shape the card names (registerCrmPositionBindings hooks kernel:bootstrapped). POSITIVE CONTROL: the same code with composeHostStack: false and extraPlugins: [...config.plugins, new AppPlugin(config, undefined, { skipSeedData: true })], the served composition, logs both hooks and captures a DATABASE_ERROR line naming each table, which proves both the fixture and the capture channel (console.warn / console.error spies). CONTROL: the apply boot's flushSchemaDdl() created os21054_account and the coverage pass examined more than zero objects. THE FIX on the migrated file: captured lines empty, neither hook logged, host|kernel:ready logged, pendingSchemaWork empty, drift zero, and the note text. THE FIX on an absent file: captured lines empty, neither hook, the table pending, the file not created. A1, A2 and A3 each red both plan cases on DATABASE_ERROR from the surviving hook, as reported.
  • The fixture instead of examples/app-crm: scripts/cross-package-test-inputs.mjs declares examples/ reads per package and per test (:200-238 the globs, :262-266 per-file rows, :351-374 the cli's own showcase rows), and check-cross-package-test-inputs.mjs --verify fails any undeclared escaping test ("an undeclared scan is a RED GATE, never a silent skip"), with turbo.json carrying the matching inputs. A cli pin importing examples/app-crm/objectstack.config.ts would need a new row there and in turbo.json, outside the claim's surface. Acceptable. The real app-crm is measured by the dev's CLI repro (five runs, 6/6 before and 0/0 after on DATABASE_ERROR and position binding lookup failed), read here as reported, not re-run. The committed pin asserts the card's pin (0 DATABASE_ERROR) and not.toContain('app|onEnable'); the position binding lookup failed string is the hook's own output and is entailed by the hook not running, but it is held by the repro, not by a committed assertion.

(d) The plan's output: one more entry in an existing open-ended notes array; not a contract change of the machine output — RIGHT.

Surface inventory: no route, flag, exit code or JSON key changes; one new optional constructor option and one new public getter on AppPlugin, an exported class of @objectstack/runtime (the ② finding); in @objectstack/cli, composeForDeclarations gains an optional second parameter and createDeclarationBootLifecycle, DeclarationBootLifecycle, WithheldHostHook and SchemaMigrationComposition.lifecycle are added, all in src/utils/schema-migration-plugins.ts, which cli/src/index.ts does not re-export (its only utils import is ./utils/invocation.js; the package's entries are ., ./console, ./hook-body), so the cli's published surface does not move; one changeset; no generated artifact; no governed path; examples/** and driver-sql untouched.

② Semver level

The PR body's line 2 and the changeset read Clause-②: no with @objectstack/runtime at patch. WRONG. The runtime diff is a purely additive widening of a published package's public surface and owes Clause-②: yes (widening) with @objectstack/runtime at minor. @objectstack/cli at patch is right.

  • The export path read: packages/runtime/src/index.ts:61 exports AppPlugin by name; packages/runtime/package.json publishes . as ./dist/index.d.ts / ./dist/index.js (import) and ./dist/index.d.cts / ./dist/index.cjs (require). The diff adds skipOnEnable?: boolean to the constructor's inline options type (app-plugin.ts:263-269 at the head, opts: { skipSeedData?: boolean; skipOnEnable?: boolean; securityMetadataRegistrar?: … }) and a public accessor get onEnableWithheld(): boolean (:259). Both join the published .d.ts; a consumer that holds the class can now pass a key it could not pass and read a member it could not read. That is both limbs of the question clause2-line.mjs states (「本卡放宽接受集或扩大公开面吗」): the constructor's accept set widens and the public surface grows.
  • The rule: pr-automation.yml:754-767 (WHICH LEVEL, the maintainer's ruling of 2026-09-04, decision batch [WIP] Add query enhancements and advanced validation features #35, on finding(changeset): two independent contract reviews read the repo's own history to opposite bumps for "add an exported symbol to a published index" #15294): "A purely additive widening of a published package's public surface (a new exported symbol on an index, a new accepted key or value) takes at least minor. The commit type may raise a bump but never lower it below what the act requires; a fix( that widens an index is therefore minor, and a fix( that changes no public surface stays patch." check-changeset-no-major.mjs:76-78 quotes the same sentence, and AGENTS.md's Post-Task Checklist 3 says yes takes at least minor. The 64 pre-rule patch precedents are declared pre-rule there; this PR is a month after the rule.
  • The precedents read the same act the same way. PR fix(rest,runtime): the published-snapshot doors answer the package's flow for a shipped flow name with a stored row, as the layered read does (#21002) #21116's record 5927277927 graded one private method made public on an exported class as Clause-②: yes (widening) with the widened package at minor, after the claim had said no and the dev re-read the line against the real diff; PR fix(runtime,metadata-protocol): the /automation write doors keep the packaged-base lock the /meta door keeps (#20679) #20817 (packagedBaseRefusal made public) and PR fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) #20853 (two new public members) did the same. In-class precedent is stronger still: the two sibling options this diff sits beside were each graded @objectstack/runtime: minor when they were added, skipSeedData in 33a5ff499e (.changeset/migrate-occupancy-and-deferred-ddl.md) and securityMetadataRegistrar in 317132495b (.changeset/artifact-boot-single-security-registrar.md). The dev's own body cites skipSeedData as the precedent for the option's shape and the claim's condition for touching the executor, but not for its level.
  • Not (narrowing): no accepted input is refused and the default path is the base's branch verbatim, so no consumer that passes nothing sees a change. Not breaking, so no ADR-0087 disposition is owed. @objectstack/cli at patch stands: a bug fix in a released package whose published entry does not move (the inventory above).
  • Why CI did not catch it: check-changeset-no-major's level axis fires only on a declared yes (:7-10, :934-935); a no with patch passes it by construction, so Check Changeset green is no evidence on ②.
  • The remedy is two lines on the same head's successor: the PR body's line 2 to Clause-②: yes (widening), and in .changeset/21054-plan-runs-no-app-hooks.md '@objectstack/runtime': patch to minor with its Clause-② line to yes (widening). Nothing in ① moves with it. The release input is what this corrects: CHANGELOG.md and the published version level are compiled from this changeset, and AGENTS.md's Documentation Guardrails make a released entry correctable only by a dedicated docs-only PR, so passing the mis-graded input would publish a runtime surface widening at patch.

③ Boundary flags

  1. Deviations, all six answered. (1) packages/runtime/src/app-plugin.ts changed under the claim's condition — the condition held (① a: the executor alone resolves the hook owner); right; the changeset covers runtime, at the wrong level (②). (2) The app-crm-shaped fixture — ① c; right. (3) The composeForDeclarations suppresses only start(), so an init()-registered kernel:ready hook still writes during os migrate plan — the guarantee holds only for hosts following an unwritten convention #13332 pins inverted in place and the three writer moves — ① b; right. (4) One merge of origin/main, 5bd79b1b3c, main tip a11faeecb3, parents 3781713631 and a11faeecb3; the incoming commits touch none of the eight paths and the lockfile did not move; AGENTS.md's Multi-agent discipline §9 and §10 ask for a refreshed build and a re-check scoped to the overlap, and the dev reports the nine touched packages rebuilt, the touched suites plus typecheck re-run, and the full gate union re-run at the final head; PR CI runs on the merge ref. Acceptable. (5) The cli integration tier run in part (11 migrate-related files) and the nightly-tier e2e files (migrate-plan-exits, json-stdout-purity, migrate-apply-refuses-before-ddl, migrate-exit-code, migrate-unloadable-host-config-exit, config-miss-stdout-purity) NOT MEASURED — the integration project's remainder is in the Test Core shards (the check-run picture); the nightly files are not in PR CI either, so the stdout-purity property is judged from source: the new line enters composition.notes (printed in the human branch, carried in the --json payload), and the executor's new log line is info on the same ctx.logger the base's Executing runtime.onEnable used, so no new channel reaches stdout. Acceptable; the dev's "plan --json stdout parsing on app-crm was measured in the repro" covers the same point. (6) The gate union re-run on the final head after a stopped loop at 6d4ef7c9aa — housekeeping; the reported union is at dc1c40ec39.
  2. Fixes #21054 would close the card correctly on a passing head. The card is a finding whose ruling is triage's grade; the grade is delivered at the head on every clause (both doors, every app, one point per door, the stop clause answered, the pin reported 0 DATABASE_ERROR and 0 position binding lookup failed on five runs), the claim names this branch, and The card this PR closes must claim this branch and Part-of PR must not also close its card are green. Because this record is FAIL, the card stays open until a PASS record on the head that carries the ② correction; the Fixes line itself is right.
  3. examples/** untouched; [finding] os migrate plan against a database that does not exist yet prints 6 [sql-driver] DATABASE_ERROR … no such table warnings: the dry run defers the DDL, then its boot reads sys_metadata, sys_metadata_activation and sys_migration anyway #20821 unaffected. The diff touches no path under examples/ (0 files). driver-sql is untouched; PR fix(driver-sql): os migrate plan on a new database prints no DATABASE_ERROR for the tables whose DDL it deferred (#20821) #21093 (cf0346ec78, [finding] os migrate plan against a database that does not exist yet prints 6 [sql-driver] DATABASE_ERROR … no such table warnings: the dry run defers the DDL, then its boot reads sys_metadata, sys_metadata_activation and sys_migration anyway #20821's fix) is an ancestor of both the dev's base 9c8b65aa23 and the merge-base, which is why the card's "on an absent file, the same lines come on top of [finding] os migrate plan against a database that does not exist yet prints 6 [sql-driver] DATABASE_ERROR … no such table warnings: the dry run defers the DDL, then its boot reads sys_metadata, sys_metadata_activation and sys_migration anyway #20821's 6" already read 6 and not 12 at the dev's base; the absent-file pin's zero relies on fix(driver-sql): os migrate plan on a new database prints no DATABASE_ERROR for the tables whose DDL it deferred (#20821) #21093 for the deferred-DDL reads and on this PR for the hook reads. [finding] os migrate plan against a database that does not exist yet prints 6 [sql-driver] DATABASE_ERROR … no such table warnings: the dry run defers the DDL, then its boot reads sys_metadata, sys_metadata_activation and sys_migration anyway #20821 is not reopened and its demotion is not widened.
  4. Named by this review, not by the dev:
    • (i) POST_DECLARATION_PHASES is a list, the shape composeForDeclarations suppresses only start(), so an init()-registered kernel:ready hook still writes during os migrate plan — the guarantee holds only for hosts following an unwritten convention #13332's claim objected to. Accepted here because the guard stays the phase-agnostic write guarantee and the list cuts reads and noise only; a fourth post-declaration phase would need adding to it. Carrier none.
    • (ii) The residues the module header states are true at the head: a host that registers through getKernel() or from a service factory is outside the composition's reach (its writes still meet the guard, its reads do not), and a host that declares objects from a post-declaration hook loses them from the plan, which the contract says is registration-over. Stated, not hidden. Carrier none.
    • (iii) Docs Drift Check (advisory) lists nine hand-written pages through the AppPlugin symbol; content/docs/protocol/kernel/lifecycle.mdx and content/docs/deployment/cli.mdx (which documents os migrate plan) were not read here. Whether either states that a plan runs app lifecycle hooks is for the docs-accuracy lane; no edit is owed in this PR.
    • (iv) Trailers: all six commits carry the model-free pair; no model identifier in the PR body (session-URL footer), the changeset, the code comments or the pins.
    • (v) check:changeset-no-major cannot see a no / patch mis-declaration (② above), so the next head's Check Changeset green will be evidence only once the line reads yes (widening) and runtime reads minor, at which point the gate's own predicate measures it.
  5. The check-run picture above: every required context converged green and no run is red, on this head and on origin/main's tip; the merge-tree is clean. None of that reaches ②: the one gate that reads the Clause-② line fires only on a declared yes, so the FAIL rests on a declaration CI cannot see, and the two-line remedy in ② is the whole of what the next head owes.

Implemented-by: claude/issue-21054-plan-no-app-hooks
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: FAIL

…ening)

AppPlugin, exported from @objectstack/runtime's root, gains the optional
skipOnEnable constructor option and the onEnableWithheld getter: an
additive widening of a published surface, which takes at least minor.
Contract review record 5928867906.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: afc44ba5bf59bd98f4a78050e48b751cb5f03835
Local-runs: none

This is the record of record for PR #21138 at afc44ba5bf, card #21054, succeeding the FAIL record 5928867906 on dc1c40ec39, which failed the PR on ② alone (the runtime widening declared Clause-②: no at patch) and found ① and ③ RIGHT. Same grade 5924795251, same claim 5926696048, same report 5928545144.

Inputs: the FAIL record 5928867906 and everything it names; the PR body re-read from the API after the seat's patch (line 2 Clause-②: yes (widening), the new ## Release grading section, the gates paragraph for afc44ba5bf); the diff git diff dc1c40ec39 refs/review/pr-21138 after git fetch origin +refs/pull/21138/head:refs/review/pr-21138; the one new commit afc44ba5bf and its trailers; the changeset at the head in full; app-plugin.ts, runtime/src/index.ts, check-changeset-no-major.mjs (:926-945, :1524-1527, :1599-1600) and scripts/pm/dispatch-gates.mjs at the head through git show; the head's check-runs, polled in the background until they converged and collapsed latest-per-name; origin/main freshly fetched (e952cff578) and its tip check-runs.

What moved, from the diff. git diff dc1c40ec39 refs/review/pr-21138 is one file, .changeset/21054-plan-runs-no-app-hooks.md, +7 / −2: '@objectstack/runtime': patch to minor, Clause-②: no to Clause-②: yes (widening), and one new closing paragraph. git diff --stat dc1c40ec39 refs/review/pr-21138 -- . ':!.changeset' is empty, and the seven code and test blobs are identical by object id at both heads (schema-migrate.ts 3ad23cf627, schema-migration-plugins.ts c6f8722f37, its unit test 35f5b0f745, the write-guard test d43625537e, the host-composition test 2009737a96, app-plugin.ts b5996d1278, app-plugin.test.ts cd393a76ee). One commit, afc44ba5bf (chore(changeset): grade the runtime widening minor, Clause-② yes (widening)), carrying the model-free trailer pair and no model identifier. The merge-base is unchanged (a11faeecb3); the net diff against it is the same eight files, now +913 / −60.

Check-runs on afc44ba5bf, read after convergence and collapsed latest-per-name (a background poll of the commit's check-runs, no local run; every run reports this head): 34 runs, 29 success, 5 skipped, 0 failure, 0 cancelled. Every required context is success: Lint & Repo Gates (the check:* family over this diff), TypeScript Type Check and its four sub-jobs (source gates, consumer gates, debt ledger, workspace), Test Core and all six shards, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check Changeset is success on this head, now evaluating a declared yes (its log is quoted under ②). Check Documentation Links, The card this PR closes must claim this branch, Part-of PR must not also close its card, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Flag docs affected by code changes and filter are success. The five skipped: Build Docs, Console Pin Gate and Packed-tarball smoke (opt-in) as on the previous head (path-filtered and opt-in), plus Auto Label and Check PR Size, which were success on dc1c40ec39 and are skipped here; both are labelers, neither is a required context, and the PR's labels (documentation, size/l, tests, tooling) are unchanged. No run is red, so there is no red to compare against origin/main; for the record, origin/main's tip e952cff578 shows no red in its latest-per-name runs (19 of 39 still in progress at the time of reading).

Mergeability: git merge-tree --write-tree origin/main refs/review/pr-21138 against the freshly fetched origin/main (e952cff578, eighteen commits past the merge-base) is clean, tree 10611fbde2, exit 0. None of the eighteen touches any of the eight paths.

① Derived judgments

Unchanged, and carried from 5928867906 ① (a) through (d), each RIGHT. The judgments there were made on the code at dc1c40ec39, and every code and test blob is byte-identical at afc44ba5bf (the object ids above; the only path in the diff is the changeset). So the two doors are still closed where each enters for every app (the executor reads skipOnEnable at app-plugin.ts:1054-1061 after resolving bundle.default before the bundle; the composition sets it at one site; every other construction path takes the default; declarationContext declines exactly kernel:bootstrapped and kernel:listening by phase; the write guard's code sits outside every hunk; all seven in-repo post-declaration hook sites are in start()), the #13332 inversions and the three writer moves are compelled with the phase-agnostic property pinned by an unwrapped writer, the pins red under A1, A2 and A3 as reported with the fixture an acceptable stand-in under the cross-package gate, and the plan's --json gains one entry in the open-ended composition.notes array and nothing else. The surface inventory stands: one optional constructor option and one public getter on AppPlugin in @objectstack/runtime, no cli entry moved.

② Semver level

The PR body's line 2 reads Clause-②: yes (widening), and the changeset grades @objectstack/runtime minor with the same line, @objectstack/cli patch. RIGHT, and the new paragraph is accurate to the code at the head.

  • Against WHICH LEVEL (pr-automation.yml:754-767): the act is a purely additive widening of a published package's public surface (an optional constructor key and a public accessor on AppPlugin, exported at runtime/src/index.ts:61 and published through package.json's . entry), so it takes at least minor; the fix( commit type does not lower it. minor is what the changeset now says.
  • Against check-changeset-no-major.mjs: the level axis asks that a PR declaring yes grade at least one package whose packages/**/src/** it moves at minor or above (:934-935); the diff moves packages/runtime/src/app-plugin.ts and grades @objectstack/runtime minor, so the declared widening is accounted for (:1599-1600, the raised set is non-empty) and the axis reads discharged (:1524). The direction arm (widening) is one of the closed pair and is not (narrowing), so no BREAKING banner and no ADR-0087 disposition is owed. The dev's event-mode run reports exactly this, with the patch body as a red control; this record reads it as reported and takes CI's Check Changeset on this head as the measurement: its job log (110315278597, read through the API, no local run) prints "LEVEL AXIS: this PR declares clause-② yes (widening), and it grades a package whose packages/**/src/** it moves at minor or above — the declared widening is accounted for: @objectstack/runtime: minor", with @objectstack/cli: patch listed as a moved package "NOT refused", declaration line: Clause-②: yes (widening), direction arm: widening; the same job's check-adr-0087-registration sees "1 non-breaking changeset(s)" and check-empty-changeset sees one declaring changeset added and none from the merge base modified. The gate names its own residual, that a second widening graded patch beside the minor would not be seen by it and "the contract review of record is what reads the diff": this record read the diff, and the only other moved package, @objectstack/cli, widens no published entry (5928867906 ①, surface inventory), so there is no second widening.
  • Against the precedents the FAIL record cited: PR fix(rest,runtime): the published-snapshot doors answer the package's flow for a shipped flow name with a stored row, as the layered read does (#21002) #21116's record 5927277927 (one public member on an exported class, yes (widening), the widened package at minor), PRs fix(runtime,metadata-protocol): the /automation write doors keep the packaged-base lock the /meta door keeps (#20679) #20817 and fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) #20853, and the two in-class precedents, skipSeedData (33a5ff499e) and securityMetadataRegistrar (317132495b), each @objectstack/runtime: minor. The grading now matches all five.
  • The new changeset paragraph, sentence by sentence against the head: "AppPlugin, exported from the package root" — runtime/src/index.ts:61, the . entry; "gains the optional constructor option skipOnEnable (default false)" — app-plugin.ts:268 declares skipOnEnable?: boolean and :275 reads opts.skipOnEnable ?? false; "and the read-only getter onEnableWithheld" — :259 declares get onEnableWithheld(): boolean and no setter exists in the file; "A composition that does not pass the option gets exactly the behaviour it had, onEnable included" — with the flag false the else if at :1062 is the base's branch verbatim and onEnable runs. True on every clause. The rest of the changeset is the dc1c40ec39 text, judged accurate in 5928867906 ① (a) and (d).
  • @objectstack/cli at patch stands for the reason given there: the cli's new exports live in src/utils/schema-migration-plugins.ts, which no published entry re-exports.
  • The ## Release grading section in the body states the same facts and names the re-grade's provenance (5928867906, commit afc44ba5bf); nothing in it overstates the surface.

③ Boundary flags

  1. Unchanged, and carried from 5928867906 ③ 1 through 4: the six deviations answered as there, with deviation (1)'s level now right; examples/** untouched (still 0 files) and [finding] os migrate plan against a database that does not exist yet prints 6 [sql-driver] DATABASE_ERROR … no such table warnings: the dry run defers the DDL, then its boot reads sys_metadata, sys_metadata_activation and sys_migration anyway #20821 unaffected (driver-sql untouched, PR fix(driver-sql): os migrate plan on a new database prints no DATABASE_ERROR for the tables whose DDL it deferred (#20821) #21093 an ancestor of the merge-base); the residues and the docs-drift note stand; trailers now hold on all seven commits.
  2. Fixes #21054 closes the card correctly on this head. The grade is delivered (the carried ①), the claim names this branch, The card this PR closes must claim this branch and Part-of PR must not also close its card are green on afc44ba5bf, and the release input is now right.
  3. The gate carry is sound for this diff. The only changed path is .changeset/21054-plan-runs-no-app-hooks.md; every family whose declared population excludes .changeset/** reads inputs that are byte-identical at the two heads (the object ids above), so its verdict at dc1c40ec39 is its verdict at afc44ba5bf. The dev reports the partition (19 families naming the changeset path or a whole-tree population re-run, 19 of 19 exit 0; 45 carried; --ran reconciling 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN) and dispatch-gates.mjs itself warns that "many gates read the whole tree" (:204), which is the class the dev re-ran rather than carried. The partition is the dev's derivation, read here as reported; it is not load-bearing for the verdict, because Lint & Repo Gates runs the whole check:* family on this head regardless (the check-run paragraph).
  4. The seat sent the body patch itself. os-dev.md reserves post-create body edits for the seat; the body's ## Release grading section says so, and the body's second line now matches the changeset's. Consistent; no deviation.
  5. The check-run picture above: every required context converged green on this head, Check Changeset included and now measuring the declared yes; no run is red here or on origin/main's tip; the merge-tree against the freshly fetched origin/main is clean. The one thing the FAIL record asked for is the one thing that moved, and it moved to the right reading.

Implemented-by: claude/issue-21054-plan-no-app-hooks
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS

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

Projects

None yet

2 participants