Skip to content

fix(core): plugin startup elapsed time is duration, the name its spec contract already declares - #16057

Draft
claude[bot] wants to merge 4 commits into
mainfrom
claude/issue-15820-plugin-startup-duration-name
Draft

fix(core): plugin startup elapsed time is duration, the name its spec contract already declares#16057
claude[bot] wants to merge 4 commits into
mainfrom
claude/issue-15820-plugin-startup-duration-name

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #15820

PluginStartupResult.startTime has always been assigned Date.now() - startTime, an elapsed duration, on both the success and the failure path. The name asserts the opposite of the value: a reader who correctly takes startTime for an instant and writes Date.now() - result.startTime gets an age near the epoch rather than a wait. An ambiguous name makes someone stop and check; this one lets them proceed confidently wrong.

The root cause is a declared-versus-enforced divergence, not drift. packages/spec/src/kernel/startup-orchestrator.zod.ts:149 already declares duration: z.number().min(0).describe('Time taken to start the plugin in milliseconds') for the same measure on the same result — the outcome of starting one plugin — so the contract surface was already correct and packages/core had drifted away from it. The right spelling is also twelve lines above the defect in the same file: PluginLoadResult.loadTime (plugin-loader.ts:85, assigned at :182) carries the identical computation under a name that does not lie. This PR aligns core to the contract that exists; it invents no name and does not touch packages/spec.

One provenance claim in the card did not survive re-measurement, and the wording here is deliberate because of it. The card attributed the core-to-spec mapping to packages/spec/api-surface/contracts.json:218 and to the PluginStartupResult rows in the package CHANGELOGs. Measured: contracts.json:218 is packages/spec's own export listing, and the CHANGELOG row describes a consolidation inside packages/spec — its own text says "Neither side had any consumer outside spec". packages/core neither imports nor references startup-orchestrator, and the two PluginStartupResult declarations share a name and no shape. So there is no declared mapping row, and this PR does not claim one. The reason to take the contract's name is undamaged and is stated on its own terms: packages/spec is this repo's one contract surface, it already names this measure duration, and core produced the same measure under a name that says the opposite. That is enough, and it does not depend on a mapping row.

What changed — three sites, all additive

Site Before After
plugin-loader.ts interface member startTime?: number duration?: number added; startTime kept, still populated, now @deprecated with a doc comment stating plainly that it holds elapsed milliseconds and not an instant
kernel.ts private field pluginStartTimes pluginStartupDurations (private; renamed outright)
kernel.ts public getter getPluginMetrics() getPluginStartupDurations() added; getPluginMetrics() becomes a @deprecated delegating alias returning the same map

Nothing is removed, so no consumer has to change on this release. That is deliberate and is the ADR-0087 route for a public type member rather than deleting the key: L1 in the ADR's ladder — the old shape keeps working while the fleet moves — instead of an L3 break. Read result.duration where you read result.startTime, and kernel.getPluginStartupDurations() where you called kernel.getPluginMetrics(); the values are identical, so the move can be made at leisure.

packages/core/ADVANCED_FEATURES.md and packages/core/examples/kernel-features-example.ts are updated so the in-package docs stop teaching the old name. skills/objectstack-platform/SKILL.md documents getPluginMetrics() and is deliberately not touched: the method still exists and still behaves identically, so that page is not wrong, and skills/** is a governed surface a code PR should not ride on.

Clause-② — measured, both limbs answered separately

Measured with the dist/index.d.ts ablation on @objectstack/core: build at head, swap both changed sources back to base e1d4f9e3f, rebuild, diff the built declaration file, restore byte-exact and prove the restore.

Limb 1 — does any exported symbol or signature move? YES. The published packages/core/dist/index.d.ts differs by 37 lines base-to-head. PluginStartupResult gains a declared member duration?: number; ObjectKernel gains a public method getPluginStartupDurations(); the emitted private member declaration changes name from private pluginStartTimes to private pluginStartupDurations. Both legs were proven to reach dist before being read: the head build carries duration?: number once and getPluginStartupDurations twice, the base-swapped build carries each zero times. Restore verified byte-exact against the HEAD blobs with git diff HEAD empty, then dist rebuilt at head.

Limb 2 — is any request newly accepted or rejected? NO. No schema, parser or validation path is touched and packages/spec is untouched. Every input the kernel accepted before it accepts now, and every rejection is unchanged. The only runtime difference is that two extra result members are populated with a value that was already computed.

Limb 1 fires on its own, so this is Clause-②: yesneeds:contract-review on this PR and on the card, and the PR stays draft until that review clears.

The #14478 convergence question, answered as triage required

Triage asked that the fork be answered here rather than left open: duration, or a unit-bearing name if the convention landing in #14478 settles on one.

Measured at base e1d4f9e3f: check:duration-unit-keys does not exist in this tree at all — zero hits repo-wide for that gate name, against a firing control (check:engine-double-contract resolves in package.json). So the convention has not landed, and durationMs cannot be the answer from a convention that is not yet in the repo. Meanwhile durationMs already appears 73 times under packages/spec/src/**, so the two spellings genuinely coexist there today.

The answer is therefore duration, and the reason is not preference: it is the name this contract declares today. Picking durationMs here unilaterally would recreate the exact divergence this card fixes, pointing the other way — core would carry a name its contract does not declare. If #15682 later widens the gate's scan to packages/core and the convention settles on a unit-bearing spelling, the spec key moves first (it is already inside that gate's packages/spec/src/** scope) and core follows it in the same change. #15682 remains open and is the place that decides it; the two must not diverge again.

The card's zero — re-run with a firing control, and one PM assumption came back FALSE

The card measured no readers of getPluginMetrics() or pluginStartTimes outside packages/core/src/kernel.ts, reverse-checked against PluginStartupResult. Re-run whole-repo at base:

  • pluginStartTimeszero outside kernel.ts holds: 3 hits, all in kernel.ts.
  • getPluginMetricsthe zero does not hold. Seven hits outside kernel.ts: packages/core/ADVANCED_FEATURES.md (2), packages/core/examples/kernel-features-example.ts (1), packages/core/src/kernel.test.ts (2), and skills/objectstack-platform/SKILL.md (2). The card's sweep was scoped to packages apps examples, which does not reach skills/.
  • Reverse control fires: PluginStartupResult returns 40-plus hits across the repo, so the search reaches.
  • Sibling repo objectui at its current checkout: zero hits for all three symbols, against a firing control (ObjectKernel / @objectstack/core hits across changesets, docs and packages/app-shell).

No promotion to p1. Every one of those readers treats the value as a duration already (${duration}ms, "startup ms", "per-plugin startup durations", toBeGreaterThan(0)); none does instant arithmetic on it, and no out-of-repo consumer was measurable. Worth recording separately: PluginStartupResult is exported but no public method of @objectstack/core returns one — startPluginWithTimeout is private — so an external consumer would have to construct the shape itself to be misled by it.

Tests

Two pin tests assert the meaning of the number, not merely that one is present. toBeGreaterThan(0) cannot separate the two readings, because an epoch-millisecond instant passes it too; a ceiling can, since any instant today is around 1.7e12, orders of magnitude above any plugin's start().

Ablated, mutation proven on disk before measuring and restore proven after, in one shell:

  • Control, unmutated, through the identical command: Test Files 1 passed (1) / Tests 2 passed | 44 skipped (46).
  • Mutated (emit the start instant where the duration belongs, in the result member and in the map): Tests 2 failed | 44 skipped (46), with AssertionError: expected 1788646580220 to be less than 1000000000 and expected 1788646580248 to be less than 1000000000 — exactly the epoch-instant reading the pin exists to catch.
  • Restore: blob back to the HEAD blob 7285dfcb54ceef699d3d8844ad5aec61e54d1c29, git diff HEAD empty.

Package suite and gates, all re-run at 4d20aa70e (the final commit):

  • pnpm --filter @objectstack/core testTest Files 50 passed (50), Tests 1206 passed (1206).
  • pnpm --filter @objectstack/core typecheck — exit 0; check:test-typecheck: OK — @objectstack/core's test layer compiles under packages/core/tsconfig.test.json.
  • pnpm lint — the full repo-wide run, exit 0. Not narrowed.
  • pnpm check:nul-bytescheck-nul-bytes: OK (scanned 7739 text file(s) ... no raw ASCII control bytes).
  • pnpm check:dts-closure41/41 declared declaration file(s) present across 4 package(s).
  • pnpm check:published-files69 publishable package(s) ... declare a files whitelist.
  • node scripts/check-adr-0087-registration.mjs --base origin/mainthis PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).
  • node scripts/check-changeset-no-major.mjs --base origin/mainThis diff introduces no major bump.
  • Also green: check:changeset-gate-self-tests, check-empty-changeset, check-changeset-fixed, check-plugin-teardown-shape, check-comment-mask-adoption, check-undeclared-dep-imports, check-skills-token-ratchet, check:kernel-hook-pairs, check:startup-registry-verdict, check:test-source-alias, check:cross-package-test-inputs, check:type-source-resolution, check:doc-authoring, check:refd-timer-probe, check:pm-governed-prose, check:single-claim-paths, check:error-code-casing.
  • Not measured, stated as such: check:published-readme-exports exits 3 with PREREQUISITE NOT MET — 41 packages are unbuilt in this worktree, and the gate's own text says "This is NOT a pass and NOT a finding: nothing was measured". No README is touched by this diff; CI builds and will measure it.

Every exit code above was captured before any pipe, and each verdict is the gate's own printed line.

Changeset level

minor, and the act decides it rather than the fix( commit type: this is a purely additive widening of a published package's public surface (a new member on an exported interface, a new method on ObjectKernel), which the Check Changeset step's WHICH LEVEL prose puts at least at minor. Nothing is removed, so there is no breaking declaration and no ADR-0087 ledger entry — the changeset states that disposition in writing anyway, and names the removal of the deprecated spellings as the change that will carry one.

Out-of-scope finding, filed not fixed

Re-deriving the anchors surfaced a wider question that is not fixed here: packages/spec's whole kernel startup-orchestrator contract — IStartupOrchestrator, PluginStartupResultSchema, StartupOptionsSchema, StartupOrchestrationResultSchema — is declared, exported and documented at content/docs/references/kernel/startup-orchestrator.mdx, and is implemented, parsed and consumed by nothing in the repo (zero implementers and zero schema consumers outside packages/spec, against a firing control). Filed as #16059 with the measurements, unassigned, for triage to route. It is a packages/spec question and folding it in here would have hidden it inside a rename.

Authored by Claude Code in session session_01ARYe3yQTQCUFm5qPYNgKaJ (https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ).


Generated by Claude Code

claude Bot and others added 3 commits September 5, 2026 22:08
…ec contract already uses

`PluginStartupResult.startTime` has always carried `Date.now() - startTime`, an
elapsed duration, so the name asserts the opposite of the value: a reader who
correctly takes it for an instant and writes `Date.now() - result.startTime`
gets an age near the epoch. `packages/spec/src/kernel/startup-orchestrator.zod.ts`
already declares the correct name for the same measure (`duration`, "Time taken
to start the plugin in milliseconds"), and `PluginLoadResult.loadTime` twelve
lines above the defect already spells the identical computation truthfully -- so
this is a declared-vs-enforced divergence between `packages/core` and the spec
contract it implements, not a naming preference.

Three sites, all additive (nothing is removed, so no consumer changes):
  - `PluginStartupResult` gains `duration?: number`; `startTime` stays,
    populated with the same value, marked `@deprecated` with a doc comment that
    states plainly what it holds (ADR-0087 L1 -- the old shape keeps working).
  - the private `pluginStartTimes` map is renamed `pluginStartupDurations`
    (private; measured zero readers outside `kernel.ts`).
  - `getPluginStartupDurations()` is added and `getPluginMetrics()` becomes a
    deprecated delegating alias.

Pin tests assert the value is a bounded elapsed duration rather than an
epoch-millisecond instant, on the success and the failure path -- the assertion
`toBeGreaterThan(0)` could never make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
`PluginMetadata` requires `init`, so the object-literal `as` casts tripped
TS2352 under `tsconfig.test.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…ctstack/core

Additive widening of a published package's public surface (a new exported
member on `PluginStartupResult`, a new method on `ObjectKernel`) takes at
least `minor` per the `Check Changeset` step's WHICH LEVEL prose; the act
wins over the `fix(` commit type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core, touching 7 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/core/ADVANCED_FEATURES.md, packages/core/examples/kernel-features-example.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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
  • 2 changed file(s) yielded no anchor (packages/core/ADVANCED_FEATURES.md, packages/core/examples/kernel-features-example.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 24 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 53cbad9f75572600ef43bb2a18071633fd6c0f68packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 53cbad9f75572600ef43bb2a18071633fd6c0f68

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

…esult implements'

Measured: `packages/core` neither imports nor references
`packages/spec/src/kernel/startup-orchestrator.zod.ts`, and nothing in the repo
implements `IStartupOrchestrator`. The two `PluginStartupResult` declarations
describe the same domain result and share no shape, so 'implements' overstated a
relationship that does not exist in code. The reason to take the contract's name
is unchanged: it is the name the spec surface declares for this measure.

Filed separately as the wider question this made visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — NOT PASSED — one rename owed — PR #16057 at head 4d20aa70

Director seat, summon #15, session_01TezFG8ZMrNH6n5VTNpPpdH (os-zhuang), 2026-09-05T23:07Z, on the maintainer's instruction 「你应该按批次执行完所有的契约复审」. Tier fuse: get_session this session reads session_context.model = last_served_model = CONTRACT_REVIEW_TIER. Readings from the PR diff, origin/main c463d03e, the stack branch claude/issue-14478-duration-unit-in-key-name at 43cc4659 (stack 2/6 landed 22:40Z), and PR #15906 (stack 3/6). Dev report 5555263077 (22:38Z) read afterwards as cross-check.

Implemented-by: session_01ARYe3yQTQCUFm5qPYNgKaJ os-dev round (branch claude/issue-15820-plugin-startup-duration-name)
Reviewed-by: session_01TezFG8ZMrNH6n5VTNpPpdH

① Derived judgments

# claim reading verdict
1 PluginStartupResult.startTime holds Date.now() - startTime on both paths Diff context: const duration = Date.now() - startTime; … startTime: duration on the success and the failure return. Triage (5550872117) reproduced line for line. correct
2 The spec contract for the same measure declares duration origin/main packages/spec/src/kernel/startup-orchestrator.zod.ts:149 reads duration: z.number().min(0).describe('Time taken to start the plugin in milliseconds'). True on main today, false on the tree this repo has already ruled into being: PR #15906 (stack 3/6, tier-PASSed, queued to land into the stack branch) renames it to durationMs and tombstones duration (retired-keys/18.kernel__PluginStartupResult__duration.ts; startup-orchestrator.mdx: "duration never [REMOVED] … renamed to durationMs in @objectstack/spec 17"). The same PR renames StartupOptions.timeout → timeoutMs and totalDuration → totalDurationMs. stale premise
3 "durationMs cannot be the answer from a convention that is not yet in the repo" The gate is in the repo: packages/spec/scripts/check-duration-unit-keys.ts (759 lines) is on the stack branch since 2/6 landed at 22:40Z, seven minutes after this head. The convention is not a preference to wait on — it is two maintainer rulings: ruling B (5518649320: a duration-shaped number key carries its unit in its name, no baseline) and the population ruling B (5548763981: the rule governs every authored duration and every runtime-emitted duration). A per-plugin startup elapsed time on a public result type is a runtime-emitted duration. That the gate cannot see a TS interface in packages/core (it scans zod schemas; #15682 widens it to workspace zod schemas, not to interfaces) does not exempt the key from the rule — the maintainer's standing rule is 「开发应该对齐协议」, and the protocol this key aligns to spells it durationMs. incorrect
4 Additive widening: duration?: number added, startTime kept @deprecated; getPluginStartupDurations() added, getPluginMetrics() deprecated alias; private map renamed Diff read; dist/index.d.ts ablation as declared (37 lines, both legs proven to reach dist). Limb 1 fires, limb 2 does not. Clause-②: yes correctly declared and hung on PR and card. ADR-0087 L1 (old spellings kept, populated) is the right route for a public type member. correct, modulo the key name
5 Pins assert meaning (ceiling INSTANT_FLOOR_MS = 1e9), mutation proven Two tests; mutation to the instant fails both with the epoch-magnitude assertion. Sound. correct
6 Changeset @objectstack/core minor, no ADR-0087 entry (nothing retired) Correct level for an additive public widening; the group is fixed so the level is moot in effect. No entry owed while nothing is removed. correct
7 skills/objectstack-platform/SKILL.md deliberately untouched (governed; still true) Accepted — a code PR must not ride on skills/**. The page will teach a deprecated alias until the removal card; that is the removal card's docs edit. accepted

The one change owed

Spell the new member durationMs?: number — the key PluginStartupResult in @objectstack/spec will carry once #15906 lands, and the spelling both rulings require for a runtime-emitted duration. Consequential edits, same round: the three doc comments and the changeset that cite PluginStartupResultSchema.duration cite durationMs; the pin tests read .durationMs; startTime: duration stays as the deprecated alias exactly as written. The method name getPluginStartupDurations() is not a key and may stay; the local const duration is not surface. Landing order is unconstrained — packages/core does not import the spec schema, so this can land before or after the stack.

Not asked: no change to the deprecation route, tests, or changeset level; no packages/spec edit here (#15906 owns that key).

Boundary flags

Evidence and gate

Checks on 4d20aa70: 33 success / 3 skipped / 0 red. check-governed-merges --test not run — no landing this round. needs:contract-review stays on this PR and on #15820. Patch round routed to the dispatching seat session_01ARYe3yQTQCUFm5qPYNgKaJ: push the rename on this branch (⛔ no rebase), post the new head here; this seat re-reads the delta and clears both carriers in one stroke.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core: PluginStartupResult.startTime carries an elapsed duration, and the spec contract for the same result declares it as duration

1 participant