fix(core): plugin startup elapsed time is duration, the name its spec contract already declares - #16057
fix(core): plugin startup elapsed time is duration, the name its spec contract already declares#16057claude[bot] wants to merge 4 commits into
duration, the name its spec contract already declares#16057Conversation
…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
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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
Contract review (clause ②) — NOT PASSED — one rename owed — PR #16057 at head
|
| # | 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
- PR body's "the spec key moves first and core follows in the same change" — the spec key is already moving in feat(spec)!: the fourteen kernel/ duration keys carry their unit in the key name (#15678, stack 3/6) #15906; the follow is this round, not a later one.
- Card core:
PluginStartupResult.startTimecarries an elapsed duration, and the spec contract for the same result declares it asduration#15820's suggested shape already allowed for this: "duration(or a unit-bearing name, if the convention landing in spec: duration-shaped number keys carry their unit in describe prose only — twottlkeys with different units in one block, baretimeoutkeys, unit-less tenant timeouts #14478 settles on one)". It has settled. - spec: the kernel startup-orchestrator contract (IStartupOrchestrator, PluginStartupResultSchema) is declared, exported and documented, and implemented by nothing #16059 (startup-orchestrator contract has zero implementers) is correctly filed, not folded. Noted for triage.
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
Fixes #15820
PluginStartupResult.startTimehas always been assignedDate.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 takesstartTimefor an instant and writesDate.now() - result.startTimegets 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:149already declaresduration: 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 andpackages/corehad 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 touchpackages/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:218and to thePluginStartupResultrows in the package CHANGELOGs. Measured:contracts.json:218ispackages/spec's own export listing, and the CHANGELOG row describes a consolidation insidepackages/spec— its own text says "Neither side had any consumer outside spec".packages/coreneither imports nor referencesstartup-orchestrator, and the twoPluginStartupResultdeclarations 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/specis this repo's one contract surface, it already names this measureduration, 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
plugin-loader.tsinterface memberstartTime?: numberduration?: numberadded;startTimekept, still populated, now@deprecatedwith a doc comment stating plainly that it holds elapsed milliseconds and not an instantkernel.tsprivate fieldpluginStartTimespluginStartupDurations(private; renamed outright)kernel.tspublic gettergetPluginMetrics()getPluginStartupDurations()added;getPluginMetrics()becomes a@deprecateddelegating alias returning the same mapNothing 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.durationwhere you readresult.startTime, andkernel.getPluginStartupDurations()where you calledkernel.getPluginMetrics(); the values are identical, so the move can be made at leisure.packages/core/ADVANCED_FEATURES.mdandpackages/core/examples/kernel-features-example.tsare updated so the in-package docs stop teaching the old name.skills/objectstack-platform/SKILL.mddocumentsgetPluginMetrics()and is deliberately not touched: the method still exists and still behaves identically, so that page is not wrong, andskills/**is a governed surface a code PR should not ride on.Clause-② — measured, both limbs answered separately
Measured with the
dist/index.d.tsablation on@objectstack/core: build at head, swap both changed sources back to basee1d4f9e3f, 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.tsdiffers by 37 lines base-to-head.PluginStartupResultgains a declared memberduration?: number;ObjectKernelgains a public methodgetPluginStartupDurations(); the emitted private member declaration changes name fromprivate pluginStartTimestoprivate pluginStartupDurations. Both legs were proven to reachdistbefore being read: the head build carriesduration?: numberonce andgetPluginStartupDurationstwice, the base-swapped build carries each zero times. Restore verified byte-exact against the HEAD blobs withgit diff HEADempty, thendistrebuilt at head.Limb 2 — is any request newly accepted or rejected? NO. No schema, parser or validation path is touched and
packages/specis 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-②: yes—needs:contract-reviewon this PR and on the card, and the PR stays draft until that review clears.The
#14478convergence question, answered as triage requiredTriage 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-keysdoes not exist in this tree at all — zero hits repo-wide for that gate name, against a firing control (check:engine-double-contractresolves inpackage.json). So the convention has not landed, anddurationMscannot be the answer from a convention that is not yet in the repo. MeanwhiledurationMsalready appears 73 times underpackages/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. PickingdurationMshere 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 topackages/coreand the convention settles on a unit-bearing spelling, the spec key moves first (it is already inside that gate'spackages/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()orpluginStartTimesoutsidepackages/core/src/kernel.ts, reverse-checked againstPluginStartupResult. Re-run whole-repo at base:pluginStartTimes— zero outsidekernel.tsholds: 3 hits, all inkernel.ts.getPluginMetrics— the zero does not hold. Seven hits outsidekernel.ts:packages/core/ADVANCED_FEATURES.md(2),packages/core/examples/kernel-features-example.ts(1),packages/core/src/kernel.test.ts(2), andskills/objectstack-platform/SKILL.md(2). The card's sweep was scoped topackages apps examples, which does not reachskills/.PluginStartupResultreturns 40-plus hits across the repo, so the search reaches.objectuiat its current checkout: zero hits for all three symbols, against a firing control (ObjectKernel/@objectstack/corehits across changesets, docs andpackages/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:PluginStartupResultis exported but no public method of@objectstack/corereturns one —startPluginWithTimeoutis 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'sstart().Ablated, mutation proven on disk before measuring and restore proven after, in one shell:
Test Files 1 passed (1) / Tests 2 passed | 44 skipped (46).Tests 2 failed | 44 skipped (46), withAssertionError: expected 1788646580220 to be less than 1000000000andexpected 1788646580248 to be less than 1000000000— exactly the epoch-instant reading the pin exists to catch.7285dfcb54ceef699d3d8844ad5aec61e54d1c29,git diff HEADempty.Package suite and gates, all re-run at
4d20aa70e(the final commit):pnpm --filter @objectstack/core test—Test 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-bytes—check-nul-bytes: OK (scanned 7739 text file(s) ... no raw ASCII control bytes).pnpm check:dts-closure—41/41 declared declaration file(s) present across 4 package(s).pnpm check:published-files—69 publishable package(s) ... declare a files whitelist.node scripts/check-adr-0087-registration.mjs --base origin/main—this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).node scripts/check-changeset-no-major.mjs --base origin/main—This diff introduces no major bump.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.check:published-readme-exportsexits 3 withPREREQUISITE 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 thefix(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 onObjectKernel), which theCheck Changesetstep's WHICH LEVEL prose puts at least atminor. 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 atcontent/docs/references/kernel/startup-orchestrator.mdx, and is implemented, parsed and consumed by nothing in the repo (zero implementers and zero schema consumers outsidepackages/spec, against a firing control). Filed as #16059 with the measurements, unassigned, for triage to route. It is apackages/specquestion 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