Filed bare by the domain:spec execution seat — ⛔ no domain:* applied, that label has a single producer. The fix lands in packages/core, not in packages/spec, so routing is triage's call.
All readings taken against origin/main at 2026-09-05T09:09Z (⛔ not a working tree).
The defect
packages/core/src/plugin-loader.ts:91-95 declares:
export interface PluginStartupResult {
success: boolean;
pluginName: string;
startTime?: number;
...
}
packages/core/src/kernel.ts assigns an elapsed duration into that field, on both the success and the failure path:
// kernel.ts:674
const startTime = Date.now();
...
// kernel.ts:685, 697
const duration = Date.now() - startTime;
...
// kernel.ts:694 (success), :704 (failure)
startTime: duration,
So a field named startTime never holds a start time. It holds milliseconds elapsed. This is not an ambiguous name that a reader has to look up — the name asserts the opposite of the value, which is the one failure mode a unit convention cannot rescue, because a reader who correctly assumes startTime is an instant will do arithmetic on it and get a number near the epoch.
It propagates one hop further: kernel.ts:69 private pluginStartTimes: Map<string, number> is fed the same duration at :687, and the map is handed out at :540 by getPluginMetrics().
What makes this more than a naming nit
The same file family already has the right name for this measure, and uses it correctly. Twelve lines above the defect, plugin-loader.ts:85 declares loadTime?: number, and :182 assigns loadTime: Date.now() - startTime — the identical computation, under a name that does not lie.
And the spec contract for this very result does not have the key at all. packages/spec/src/kernel/startup-orchestrator.zod.ts — the contract PluginStartupResult maps to, per packages/spec/api-surface/contracts.json:218 and the PluginStartupResult → kernel/startup-orchestrator.zod rows in the package CHANGELOGs — declares:
:149 duration: z.number().min(0).describe('Time taken to start the plugin in milliseconds')
:196 totalDuration: z.number().min(0).describe('Total time taken for all plugins in milliseconds')
The contract says duration. The implementation ships startTime holding that same duration. This is a declared ≠ enforced divergence between packages/core and the packages/spec contract it implements, and the divergence is the root cause — the name did not drift on its own, it drifted away from a contract that was already correct.
Blast radius, measured
PluginStartupResult is exported from packages/core/src/plugin-loader.ts, so startTime is public type surface.
- Producers/consumers of the value, whole-repo (
git grep over packages apps examples): kernel.ts:371 is the only call site of startPluginWithTimeout, and there are no readers of getPluginMetrics() or pluginStartTimes outside packages/core/src/kernel.ts. That zero is reverse-checked — the same search returns four live hits for PluginStartupResult itself, so the search reaches.
- Practical effect: the rename is contained inside
packages/core, with the public type surface being the part that needs an ADR-0087 disposition rather than a silent edit.
Suggested shape (not a dispatch)
Align the implementation with the contract it already has: the elapsed measure is duration (or a unit-bearing name, if the convention landing in #14478 settles on one), the tombstone/retirement handled per ADR-0087 for the exported type, and pluginStartTimes / getPluginMetrics() renamed to say what they return.
Relationship to #14478 — read this before merging the two
This was surfaced while verifying the #14478 stack, and it is adjacent to that epic, not inside it:
Hence: separate card, bare, for triage to route.
Filed bare by the
domain:specexecution seat — ⛔ nodomain:*applied, that label has a single producer. The fix lands inpackages/core, not inpackages/spec, so routing is triage's call.All readings taken against
origin/mainat2026-09-05T09:09Z(⛔ not a working tree).The defect
packages/core/src/plugin-loader.ts:91-95declares:packages/core/src/kernel.tsassigns an elapsed duration into that field, on both the success and the failure path:So a field named
startTimenever holds a start time. It holds milliseconds elapsed. This is not an ambiguous name that a reader has to look up — the name asserts the opposite of the value, which is the one failure mode a unit convention cannot rescue, because a reader who correctly assumesstartTimeis an instant will do arithmetic on it and get a number near the epoch.It propagates one hop further:
kernel.ts:69private pluginStartTimes: Map<string, number>is fed the samedurationat:687, and the map is handed out at:540bygetPluginMetrics().What makes this more than a naming nit
The same file family already has the right name for this measure, and uses it correctly. Twelve lines above the defect,
plugin-loader.ts:85declaresloadTime?: number, and:182assignsloadTime: Date.now() - startTime— the identical computation, under a name that does not lie.And the spec contract for this very result does not have the key at all.
packages/spec/src/kernel/startup-orchestrator.zod.ts— the contractPluginStartupResultmaps to, perpackages/spec/api-surface/contracts.json:218and thePluginStartupResult → kernel/startup-orchestrator.zodrows in the package CHANGELOGs — declares:The contract says
duration. The implementation shipsstartTimeholding that same duration. This is a declared ≠ enforced divergence betweenpackages/coreand thepackages/speccontract it implements, and the divergence is the root cause — the name did not drift on its own, it drifted away from a contract that was already correct.Blast radius, measured
PluginStartupResultis exported frompackages/core/src/plugin-loader.ts, sostartTimeis public type surface.git grepoverpackages apps examples):kernel.ts:371is the only call site ofstartPluginWithTimeout, and there are no readers ofgetPluginMetrics()orpluginStartTimesoutsidepackages/core/src/kernel.ts. That zero is reverse-checked — the same search returns four live hits forPluginStartupResultitself, so the search reaches.packages/core, with the public type surface being the part that needs an ADR-0087 disposition rather than a silent edit.Suggested shape (not a dispatch)
Align the implementation with the contract it already has: the elapsed measure is
duration(or a unit-bearing name, if the convention landing in #14478 settles on one), the tombstone/retirement handled per ADR-0087 for the exported type, andpluginStartTimes/getPluginMetrics()renamed to say what they return.Relationship to #14478 — read this before merging the two
This was surfaced while verifying the #14478 stack, and it is adjacent to that epic, not inside it:
ttlkeys with different units in one block, baretimeoutkeys, unit-less tenant timeouts #14478's gatecheck:duration-unit-keysscanspackages/spec/src/**, so it cannot seepackages/coretoday. Card 6/6 ([#14478 stack 6/6] widencheck:duration-unit-keysfrompackages/spec/src/**to every workspace package's zod schemas (folds #15642) and convert the one turso offender it finds #15682) is the card that widens the declared population, and the name-level catch would fall to it once it does.packages/coreto an existing spec contract, which is an engine-lane change in a different package. Folding it into a spec-lane rename card would put the work in the wrong lane and hide a contract divergence inside a naming sweep.Hence: separate card, bare, for triage to route.