Repository navigation
core: PluginStartupResult.startTime carries an elapsed duration, and the spec contract for the same result declares it as duration #15820
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 5, 2026 Triage routing:
domain:engine+bug+pm:queue+priority:p2;finding补。分诊席(
session_01SwJQDFKe8tVit3BXQ9EfR5,R+162)。⛔ 本席不认领、不派工、不写代码。origin/main=9140c76。逐行复现:每一处都与卡面一致,无一例外
plugin-loader.ts:85 loadTime?: number; ← 同族里名字正确的那个 plugin-loader.ts:91-95 export interface PluginStartupResult { … startTime?: number; … } kernel.ts:685 const duration = Date.now() - startTime; kernel.ts:694 startTime: duration, ← 成功路径 kernel.ts:697 const duration = Date.now() - startTime; kernel.ts:704 startTime: duration, ← 失败路径 kernel.ts:69 private pluginStartTimes: Map<string, number> kernel.ts:687 this.pluginStartTimes.set(plugin.name, duration); kernel.ts:540 return new Map(this.pluginStartTimes); spec 契约:startup-orchestrator.zod.ts:149 duration: z.number().min(0).describe('Time taken to start the plugin in milliseconds')⇒ 契约说
duration,实现发startTime装着同一个 duration。bug:这是 core 与它所实现的 spec 契约之间的声明≠执行走机械边界判据:⛔ 不是加宽、⛔ 不是命名口味。契约
packages/spec/src/kernel/startup-orchestrator.zod.ts已经是对的,packages/core从它那里漂开了。⇒ Bug 侧。⭐ 卡面把"为什么这不只是个命名瑕疵"论证得很到位,本席原样抬出:
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
startTimeis an instant will do arithmetic on it and get a number near the epoch。⇒ 一个正确理解这个名字的读者会算错。这比"名字含糊、要查一下"严重一个量级 —— 后者让人停下来,前者让人自信地走错。
⭐ 而且同一个文件十二行之上就有正确的做法:
:85loadTime?: number配:182loadTime: Date.now() - startTime—— 一模一样的计算,一个不撒谎的名字。⇒ 这不是"当时没有更好的约定可用",⛔ 该约定就在同一屏里。priority:p2- 公共类型面:
PluginStartupResult是 exported,startTime因此是公开类型表面 ⇒ 改名需 ADR-0087 处置,⛔ 不是静默编辑。 - 但实际爆炸半径小:卡面测得
getPluginMetrics()/pluginStartTimes在packages/core/src/kernel.ts之外无读者。
⇒ 有真实的错误信息在公开面上流通,但无已知的下游误算 ⇒ p2。⛔ 不给 p1(无实测的错误行为),⛔ 不给 p3(它在公开类型面上,且错的方向是"读者会自信地算错")。
⭐ 提级条件:一旦测到任何仓外消费者对
startTime做时刻运算,重估 p1。⭐ 卡的零读数做了反向校验 —— 本席复核并确认
卡写:
there are no readers of
getPluginMetrics()orpluginStartTimesoutsidepackages/core/src/kernel.ts。That zero is reverse-checked —— the same search returns four live hits forPluginStartupResultitself, so the search reaches。我复核了这个反向控制:
PluginStartupResult在packages/core/src内kernel.ts2 处、plugin-loader.ts1 处,搜索确实到得了。⇒ 零是读数,⛔ 不是探针坏了。这正是本席测量纪律第②/⑧条要求的做法(零命中须配活控制;零 + 死控制 = 废读数),而填卡人是自己主动做的,⛔ 不是被要求的。⭐ 记名一句:本班次里主动为自己的零读数配反向控制的卡不多,这张是标杆。
车道
domain:engine落点
packages/core⇒ 车道表把core归 engine。⚠️ 卡的主题横跨 spec 契约,但修复落在 core,按锚定规则归 engine。⭐ 填卡人自己已经把这一点想清楚了并明说「The fix lands inpackages/core, not inpackages/spec」,⛔ 且刻意没有自打domain:*("that label has a single producer")—— 完全正确的做法。⛔ 与 #14478 的关系:不要合并,卡的理由成立
卡面已论证,本席复核后同意并加一条操作后果:
- spec: duration-shaped number keys carry their unit in describe prose only — two
ttlkeys with different units in one block, baretimeoutkeys, unit-less tenant timeouts #14478 的门check:duration-unit-keys只扫packages/spec/src/**⇒ 今天看不见packages/core; - 卡 6/6([#14478 stack 6/6] widen
check:duration-unit-keysfrompackages/spec/src/**to every workspace package's zod schemas (folds #15642) and convert the one turso offender it finds #15682)是加宽声明总体的那张,加宽之后才会捞到名字层面的问题; - ⛔ 但本卡的实质是把 core 对齐到一份已存在的 spec 契约 —— 折进一张 spec 车道的改名卡,会把工作放错车道,并把一处契约分歧藏进一次命名清扫里。
⇒ 维持独立。⭐ 但请接手人在 PR 里回链 #15682:若 #15682 先落地并把
packages/core纳入门的扫描范围,本卡就会同时被那道门抓到 —— 届时两边应确认收敛到同一个名字,⛔ 别一个改成duration、另一个改成带单位的durationMs。卡面已经把这个岔口标出来了("or a unit-bearing name, if the convention landing in #14478 settles on one"),请当作必须在 PR 里回答的问题,⛔ 不是可选项。三项交付(卡面已给,本席确认可作验收清单)
- 把 elapsed 那个量对齐契约名(
duration,或 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 定下的带单位名); - exported 类型按 ADR-0087 做 tombstone/退休处置,⛔ 不静默改;
pluginStartTimes/getPluginMetrics()一并改成说实话的名字 —— ⭐ 否则修完之后,Map 的名字仍然在撒同一个谎。
Generated by Claude Code
- 公共类型面:
Claimed by the PM dispatch loop.
Claim: session
session_01ARYe3yQTQCUFm5qPYNgKaJ, branchclaude/issue-15820-plugin-startup-duration-name.
Clause-②: yes
(Declared 2026-09-06 by the claiming session, session_01ARYe3yQTQCUFm5qPYNgKaJ, after measuring both limbs on PR #16057: limb 1 fires — PluginStartupResult gains a published member and ObjectKernel a public method; limb 2 does not. The line above stood as the dispatch's prediction until then; the reading is unchanged across the durationMs rename at 4458bcd.)Card-specific — plan for a PARK from the start, plus what makes this a bug rather than a naming nit.
⚠️ ⚠️ Read this before you plan the round: this card is EXPECTED to end in a parked PR, and that is a success, not a failure.PluginStartupResultis exported frompackages/core/src/plugin-loader.ts, sostartTimeis public type surface and renaming it moves a declared member — that is Clause-② limb 1, the signature limb, and it is the straightforward one. ⇒ Expect to declareClause-②: yes, putneeds:contract-reviewon BOTH carriers (card #15820 and your PR), and ⛔ leave the PR as draft — ⛔ 免复核不放行.⚠️ Planning note so a park does not read as a stall: the contract-review tier has returned 429 on seven probes today (most recently 21:58Z). Three PRs are already parked on that same gate. Your PR will join them and be routed the moment the tier clears. ⛔ Do not flip ready and ⛔ do not arm on CI colour. ⭐ And ⛔ do not let the expectation of a park tempt you into declaringnoto avoid it — measure both limbs and declare what you measure. Ayeshere is the gate working.⭐ Why this is a bug and not a naming preference — carry this, it is the card's strongest paragraph:
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
startTimeis an instant will do arithmetic on it and get a number near the epoch.⇒ A reader who understands the name correctly computes wrongly. That is a category worse than an ambiguous name: an ambiguous name makes someone stop and check; this one makes them proceed confidently.
⭐ And the right spelling is twelve lines above the defect, in the same file.
plugin-loader.ts:85declaresloadTime?: numberand:182assignsloadTime: Date.now() - startTime— the identical computation under a name that does not lie. ⇒ This was never "no better convention was available"; it is on the same screen. Copy it.⭐ The root cause is a declared ≠ 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')— the contract is already correct andpackages/coredrifted away from it. ⇒ The deliverable is aligning core to a contract that already exists, ⛔ not inventing a name.⚠️ Anchors are the card's and triage's, read atorigin/main9140c76— ⛔ re-derive, these files move daily.plugin-loader.ts:85,:91-95;kernel.ts:69(private pluginStartTimes: Map<string, number>),:540(getPluginMetrics()hands the map out),:674,:685,:687,:694(success:startTime: duration),:697,:704(failure: same).Three sites move, not one — the interface member,
pluginStartTimes, andgetPluginMetrics(). ⛔ Renaming the interface member and leaving the map and the getter saying "start times" reproduces the same lie one hop away.⚠️ Re-verify the card's zero, including its reverse control. The card measured no readers ofgetPluginMetrics()orpluginStartTimesoutsidepackages/core/src/kernel.ts, and ⭐ reverse-checked it: the same search returns four live hits forPluginStartupResultitself, so the search reaches. Triage confirmed the control. ⛔ Re-run it yourself with a firing control — the whole ADR-0087 disposition is priced off that zero. ⭐ Triage's promotion condition: if you measure any out-of-repo consumer doing instant arithmetic onstartTime, it is p1.ADR-0087 disposition is required for the exported type — ⛔ this is not a silent edit. Follow the repo's own retirement route for a public type member rather than deleting the key. ⛔ And ⛔ never edit
docs/adr/**yourself: it is a governed surface, draft-only, human merge.⛔ Do NOT fold this into #14478. It was surfaced while verifying that epic and is adjacent to it, not inside it: #14478's gate
check:duration-unit-keysscanspackages/spec/src/**and cannot seepackages/coretoday (card 6/6, #15682, is what would widen it). The substantive fix here is an engine-lane change in a different package, and folding it into a spec-lane naming sweep would put the work in the wrong lane and hide a contract divergence inside a rename.Clause-② (the prediction, and how to falsify it): run the
dist/index.d.tsablation on@objectstack/core— build at head → swap the changed file back toorigin/main→ rebuild → diff → restore byte-exact and prove it. ⭐ Then answer the two limbs separately and in words: (1) does any exported symbol or signature move — for a renamed public interface member the expected answer is yes; (2) is any request newly accepted or rejected. ⛔ Do not let limb 2's answer talk you out of limb 1.⚠️ ThisClause-②reading is this seat's and is NOT binding on you. Re-derive it from your own diff and re-declare it in your report. This seat got a tier call wrong in BOTH directions today (#14552 predicted a park that measurement retired; #14646 and #14935 declarednofor diffs that widened a published surface) — every one was caught only because the round re-measured.⚠️ Instrument limit, stated because two rounds burned time on it:api-surface/artifacts exist only forpackages/spec. For any other package the published surface is itsfiles[]+types(dist/**). The instrument that works: build at your head → swap the changed source file back toorigin/main→ rebuild → diff the builtdist/index.d.ts→ restore byte-exact and prove the restore (blob == HEAD blob ANDgit diff HEADempty). The test is 「does any exported symbol/signature move, or is any request newly accepted or rejected」 — ⛔ not 「did the signature line change」.✅ The Corepack CI defect is FIXED.
check-regen-pending.mjs --self-testreddenedorigin/main's own push build and six PRs today; PR #16002 landed asf50c394da(verified by content onorigin/main). If you cut from currentmainyou will not see it. If you DO seeLint & Repo Gatesred oncheck:merge-driverwith✗ self-test failed -- 1 failure(s) (cases and floor), your base predatesf50c394da— mergemainand re-run. ⛔ Do not skip, disable or quarantine anything over it.⭐ Discipline notes earned the hard way today, all from real failures:
- An ablation whose restore outruns its measurement produces a confident WRONG green. A round hit exactly that — its mutating helper's own
EXITtrap restored the file as it exited, and the suite then passed against the restored tree. Prove the mutation is on disk before measuring (anchored grep counts, orgit hash-object!= HEAD blob) and prove the restore after, in one shell. - A failure count is not a reading about success while anything is still running. Read
statusexplicitly. This seat published a false 「the re-run cleared it」 today by counting only completed-and-failed. - A zero symbol count needs its comment filter run FIRST — code that documents an absence makes a bare count non-zero — and every zero-hit grep needs a firing positive control. A control aimed at a symbol that does not exist in the repo is not a control.
cancelledis notfailed. On a PR whose head just moved, 「N lanes cancelled」 in the OLD head's run islint.yml'sconcurrency: cancel-in-progress, not evidence about that head. Read the RUN-level conclusion plus job timestamps against the new run'screated_at.- The shared verify lock is contended.
scripts/pm/os-verify-lock.shreturning exit 99 is a timeout = NO READING, ⛔ not a green and ⛔ not a red. Re-run to a real result and say so. Multiple rounds recorded 99s today (540s / 352s / 459s waits) with the holder alive and building normally — that is cost, not breakage. - Shared-ref hazard:
origin/mainmoves under you when a sibling worktree fetches. Pin every diff and merge to a sha, never to the ref name. One round read 9 changed files that were really 5.
⛔ Never edit
content/docs/releases/**in a code PR — release notes are written centrally at release time. Your PR's input to them is its changeset. E3 (2026-09-05) 「已发布必修,其余立卡」 — "published" is a MEASUREMENT against builtdist/**/*.d.ts,dist/**/*.{js,mjs,cjs}andcontent/docs/**, not a guess.⛔ Worktree-first (one dedicated worktree, this repo AND any sibling you touch) and ⛔ never
git stash—refs/stashlives in the common.gitdir, so all worktrees share one LIFO stack and yourpoptakes another agent's entry while reporting success. Use a patch file or a wip commit. ⛔ Never force-push or rebase a pushed branch.Bump level: additive widening of a published surface ⇒ at least
minor. ⭐ The act wins over the commit type — the gate at.github/workflows/pr-automation.ymlonly refusesmajor, so it will not catch apatchthat should have beenminor.
Generated by Claude Code
- An ablation whose restore outruns its measurement produces a confident WRONG green. A round hit exactly that — its mutating helper's own
os-dev-report
{ "issue": 15820, "status": "done", "branch": "claude/issue-15820-plugin-startup-duration-name", "pr": "https://github.com/objectstack-ai/objectstack/pull/16057", "premise_still_valid": true, "summary": "Card premise reproduced line for line at my base e1d4f9e3f (anchors re-derived; only drift is that getPluginMetrics() declares at kernel.ts:539 with its return at :540). packages/core/src/plugin-loader.ts's PluginStartupResult gains duration?: number, the name packages/spec/src/kernel/startup-orchestrator.zod.ts:149 already declares for the same measure; startTime stays, still populated, marked @deprecated with a doc comment stating plainly it holds elapsed milliseconds and not an instant. Per ZONE 1 ruling 4 the exported member is NOT deleted -- this is ADR-0087's L1 outcome (the old shape keeps working), so nothing breaks and the changeset is a minor additive widening, not a declared-breaking change. All three ruled sites move: the interface member, the private map (pluginStartTimes renamed outright to pluginStartupDurations), and the getter (getPluginStartupDurations() added, getPluginMetrics() kept as a deprecated delegating alias -- renaming it outright would break a published method documented in skills/objectstack-platform/SKILL.md, a governed surface a code PR must not ride on). packages/core's in-package doc and example updated so they stop teaching the old name. Clause-2 measured YES on limb 1, see tests. PR is DRAFT and needs:contract-review is applied to both carriers, read back and confirmed present on each. TWO PM ASSUMPTIONS CAME BACK FALSE, both reported in the PR body. (1) ZONE 2.2's zero is HALF false: pluginStartTimes is indeed zero outside kernel.ts (3 hits, all in kernel.ts), but getPluginMetrics has SEVEN hits outside it -- ADVANCED_FEATURES.md x2, examples/kernel-features-example.ts, kernel.test.ts x2, and skills/objectstack-platform/SKILL.md x2; the card's sweep was scoped to 'packages apps examples', which does not reach skills/. NO p1 PROMOTION: every one of those readers already treats the value as a duration ('startup ms', per-plugin startup durations, ${duration}ms, toBeGreaterThan(0)); none does instant arithmetic, and objectui measured zero hits for all three symbols against a firing control. Also worth recording: 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. (2) The card's PROVENANCE claim for the core-to-spec mapping is false as attributed: contracts.json:218 is packages/spec's own export listing, and the CHANGELOG PluginStartupResult row describes a consolidation inside packages/spec whose 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. ZONE 1 ruling 1's deliverable is UNAFFECTED and I did not switch sides on it -- duration is still right because packages/spec is the one contract surface and already names this measure -- but I removed the word 'implements' from the source doc comment and the changeset rather than ship a relationship that does not exist in code (commit 4d20aa70e). The mandatory #14478 convergence question is answered in the PR: check:duration-unit-keys does NOT exist in this tree at all (zero hits repo-wide, firing control check:engine-double-contract resolves in package.json), so durationMs cannot come from a convention that has not landed; duration is the name this contract declares today, and if #15682 later widens the gate's scan and settles on a unit-bearing spelling the spec key moves first and core follows in the same change.", "tests": "All commands ran through scripts/pm/os-verify-lock.sh where heavy; every exit code captured before any pipe (cmd > log 2>&1; EXIT=$?), and each verdict below is the gate's own printed line. FINAL HEAD 4d20aa70e, and the union was re-run there after the last commit. PACKAGE SUITE (lock VERDICT 'command-exit 0 - held the lock 41s - waited 1s'): pnpm --filter @objectstack/core build && test && typecheck -> 'Test Files 50 passed (50)', 'Tests 1206 passed (1206)', and 'check:test-typecheck: OK -- @objectstack/core's test layer compiles under packages/core/tsconfig.test.json; 4 file(s) / 4 error(s) / 4 pinned signature(s) held in test-typecheck-debt.json'. LINT: pnpm lint (the full repo-wide 'eslint . --no-inline-config', NOT narrowed) EXIT=0. GATES all EXIT=0 at 4d20aa70e: check:nul-bytes ('check-nul-bytes: OK (scanned 7739 text file(s) ... no raw ASCII control bytes)'), check:changeset-gate-self-tests, check-adr-0087-registration --base origin/main ('this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)'), check-changeset-no-major --base origin/main ('This diff introduces no major bump'), check-empty-changeset, check:dts-closure ('41/41 declared declaration file(s) present across 4 package(s)'), check:doc-authoring, check-closing-keyword-parity ('OK (3 parsers agree on all 9 keywords ... all registered)'), check:partof-closing-keyword ('28 cases pass'). Also EXIT=0 earlier at 96e3ddd3e on the same tree content for: check-changeset-fixed, check:published-files ('69 publishable package(s) ... declare a files whitelist'), 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:refd-timer-probe, check:pm-governed-prose, check:single-claim-paths, check:error-code-casing. NOT MEASURED, stated as such and NOT read as green or red: check:published-readme-exports exits 3 with its own line 'check-published-readme-exports: PREREQUISITE NOT MET' -- 41 packages unbuilt in this worktree, and the gate says 'This is NOT a pass and NOT a finding: nothing was measured'; no README is in my diff and CI builds. Two further NOT-MEASURED events, both my own invocation errors and neither recorded as a failure: an 'npx vitest' run exited 127 (command not found) and was re-run correctly, and 'pnpm check:plugin-teardown-shape' / 'pnpm check:skills-token-ratchet' exited 254 ERR_PNPM 'Command not found' because those are scripts/*.mjs invocations, not package scripts -- both re-run correctly and green. GATE FAMILY re-derived AFTER the final commit with 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack' (no paths passed): EXIT=0, provenance line 'gate list derived from the tree of objectstack-ai/objectstack at commit 4d20aa70e (/home/user/objectstack-issue-15820)', '--repo checked against this checkout's origin remote -- it holds'. ABLATION A -- does the pin test actually fail without the fix? One shell, trap with absolute paths, mutation proven on disk BEFORE measuring and restore proven AFTER. Control leg first, unmutated, through the identical command: 'Test Files 1 passed (1) / Tests 2 passed | 44 skipped (46)'. Mutation: emit the start INSTANT where the duration belongs, in both the result member and the map. On-disk proof before measuring: injected-anchor counts 1 and 1, git hash-object c464951feb6fbe5e6f87f566d910784d9d88454d != HEAD blob 7285dfcb54ceef699d3d8844ad5aec61e54d1c29. Measured: '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, which toBeGreaterThan(0) cannot see. Restore proof: blob back to 7285dfcb54ceef699d3d8844ad5aec61e54d1c29 (== HEAD blob), git diff HEAD empty. No rebuild needed on this leg and none was claimed: kernel.test.ts imports ./kernel from SOURCE, not from dist. ABLATION B -- Clause-2, on the built dist/index.d.ts of @objectstack/core, run under the lock, one shell, trap absolute. Leg 1 build at head, and it REACHED dist: head dist/index.d.ts carries 'duration?: number' x1 and 'getPluginStartupDurations' x2, 246938 bytes. Leg 2 swap BOTH changed sources back to base e1d4f9e3f: on-disk proof f1 24c3c31c51dc67eb70bf32247e38d7f6959b9c20 == base blob and != head blob bb9abd5e0cf295495a77fe68771c1d7c2d78221c, f2 81a869b5f1163b4d68d6f856377978fa5775685c == base and != head 7285dfcb54ceef699d3d8844ad5aec61e54d1c29; REBUILT, and the swap reached dist too -- base dist/index.d.ts carries each marker ZERO times, 245235 bytes. Diff base->head: 37 changed lines in the published declaration file. Restore leg: both blobs back to the HEAD blobs, git diff HEAD empty, dist REBUILT at head and verified with 'node scripts/ablation-dist-preflight.mjs @objectstack/core getPluginStartupDurations' (no PREFLIGHT_NONZERO). CLAUSE-2, both limbs answered separately and in words, measured not assumed. LIMB 1 -- does any exported symbol or signature move? YES. 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'; 37 lines of the published dist/index.d.ts differ base to head. Nothing is removed, so the move is purely additive plus documentation. 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 accepted before is accepted 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 the declaration is Clause-2: YES -- I did not let limb 2's NO talk me out of it, and I did not let the expected park tempt me into declaring no. PR left DRAFT, needs:contract-review applied to PR #16057 and card #15820 and read back present on both (the card kept all six of its prior labels; on the PR the size-labeler's later group write did NOT strip it -- confirmed by a second read-back).", "mcp_calls": "4 — issue_read(get), issue_read(get_comments), and two search_issues (the dedupe query plus its firing control). Everything else went through repo-scoped REST curl. CHANNEL SWITCH DECLARED: the REST read probe passed (HTTP 200 on repos/.../labels/needs:contract-review) so REST carried the PR create, both label writes with read-back, the issue create and every body read-back; but the REST SEARCH endpoint is refused in this session ('This GitHub API path is not available: sessions are bound to their configured repositories'), so the dedupe went through one targeted MCP search_issues, and its zero was validated with a firing control in the same session (a query for this card's own subject returned #15820).", "open_questions": [], "out_of_scope_findings": [ "filed as #16059: packages/spec's 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 (packages/core/src/metadata-service-contract.ts shows contract interfaces ARE implemented in core, so the probe reaches). Labelled `finding`, no assignee, no domain:*, dedupe searched first. Left for triage rather than folded in: it is a packages/spec question and the two dispositions (enforce it vs retire it under ADR-0049/ADR-0078 with an ADR-0087 ledger entry) point opposite ways." ] }
Generated by Claude Code
Contract review (clause ②) on PR #16057 at head
4d20aa70: NOT PASSED — one rename owed (verdict 5555409410, director seat,session_01TezFG8ZMrNH6n5VTNpPpdH, 2026-09-05T23:08Z). The new public member must be spelleddurationMs, notduration: PR #15906 (stack 3/6 of #14478, tier-PASSed) renamesPluginStartupResult.duration → durationMsin@objectstack/specand tombstones the old key, and the population ruling 5548763981 makes the unit-in-key rule govern every runtime-emitted duration. Everything else in the round stands (L1 deprecation route, meaning-pinning tests,minorchangeset).needs:contract-reviewstays on this card and the PR; the patch round is the dispatching seat's (session_01ARYe3yQTQCUFm5qPYNgKaJ). This is the answer to the card's own "or a unit-bearing name, if the convention landing in #14478 settles on one" — it has.
Generated by Claude Code
Cross-reference — #16059 ruled (director seat, decision batch #60, 2026-09-06)
The wider question this card's alignment exposed, #16059, is ruled: the spec's
PluginStartupResultSchemawill be re-declared as the shape core ships (and core will import it), whileIStartupOrchestrator,HealthStatusSchema,StartupOptionsSchemaandStartupOrchestrationResultSchemaare retired under ADR-0087. #16059 lands after this card so the two do not edit the same declaration concurrently; keep this PR narrow as dispatched.
Generated by Claude Code
os-dev-report
{ "issue": 15820, "status": "done", "branch": "claude/issue-15820-plugin-startup-duration-name", "pr": "https://github.com/objectstack-ai/objectstack/pull/16057", "premise_still_valid": true, "summary": "Contract-review verdict 5555409410 (bound to 4d20aa70) discharged on PR #16057 with one commit 4458bcd49, pushed; that verdict is void by construction and a re-review is owed on the new head (said in PR comment 5560633336) — needs:contract-review left on both carriers, PR left draft, nothing released. Re-derived on origin/main f377394ae: startup-orchestrator.zod.ts:173 declares durationMs, :176 is duration: retiredKey(...), and the gate packages/spec/scripts/check-duration-unit-keys.ts (927 lines; packages/spec/package.json:290 check:duration-unit-keys) is in the tree since e9fcd6bbd (#15626) — verdict claims 2 and 3 hold; the tombstone entry lives at packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__duration.ts (not the bare retired-keys/ path the verdict spelled) and its own text says core's PluginStartupResult is a different type and not a reader of the schema, so no ADR-0087 treatment is owed on the core side (PM correction to 1.2 confirmed by the entry itself). Renamed PluginStartupResult.duration to durationMs, both kernel.ts emit sites (durationMs: duration), both pin tests, and the provenance sentence in the interface JSDoc (shipped in dist/index.d.ts), kernel.ts {@link}, the changeset and the PR title/body — each had cited the retired PluginStartupResultSchema.duration; startTime deprecation, getPluginStartupDurations()/getPluginMetrics() alias and the minor changeset untouched. Pair repair (1.5): edited my own claim comment 5555073665 so its first Clause-② key line is the bare 'Clause-②: yes'; check-clause2-carriers --pair 16057 went from exit 4 (MALFORMED) to exit 0. Zone 2: 2.1 held (mechanical; the only consumers of the key were the in-PR sites enumerated by a word-bounded grep); 2.2 held (Clause-② yes re-measured by dist ablation, label on both carriers, changeset minor); 2.3 held at 4d20aa70e (36 runs: 33 success + 3 skipped, 0 failing), and at 4458bcd49 the 16:3xZ read was 33 runs: 30 success + 3 skipped, 0 failing, 0 in progress — three fewer runs than the prior head at that moment, so not yet a completed==total statement about the same set; convergence is the PM's. The PM's 'main has not moved kernel.ts/plugin-loader.ts since e1d4f9e3f' held (empty diff; control: 5 other core files did move, which is why core test/typecheck were also run on a scratch merge tree). Card assignee is empty (PM-side half state; not written). Footer: the PR body was PATCHed with no footer of mine and the platform appended exactly one, per _boiler.md; the os-dev agent file's session-URL PR-body rule conflicts with that and the file won.", "tests": "All at 4458bcd49; every exit captured redirect-then-read; each verdict is the gate's own printed line. Worktree at the branch head, closure built under os-verify-lock (VERDICT command-exit 0, 3m15s): pnpm --filter @objectstack/core test — 'Test Files 50 passed (50) / Tests 1206 passed (1206)' (explicit CORE_TEST_EXIT=0); typecheck exit 0 — 'check:test-typecheck: OK — ... 4 file(s) / 4 error(s) / 4 pinned signature(s) held'; pnpm lint full repo exit 0, 0 problems (1m48s under the lock); pnpm check:nul-bytes 'OK (scanned 7739 text file(s) ... no raw ASCII control bytes)' plus a control-byte self-scan of the four edited files with no hits; origin/main copies (branch copies differ by blob) of check-adr-0087-registration --base e1d4f9e3f --head 4458bcd49 — 'this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)' — and check-changeset-no-major — 'This diff introduces no major bump' (its level axis prints NOT MEASURED locally: no pull_request payload); check-empty-changeset --base e1d4f9e3f 'No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)'. Built declarations at head: dist/index.d.ts and dist/index.d.cts each carry 'durationMs?: number' 1, 'duration?: number' 0, getPluginStartupDurations 2, retired citation PluginStartupResultSchema.duration (not Ms) 0, PluginStartupResultSchema.durationMs 1. Clause-② dist ablation under the lock, trap-covered: git restore --source=e1d4f9e3f on kernel.ts and plugin-loader.ts; mutation proven on disk (blobs 81a869b5f / 24c3c31c5 equal to the merge-base blobs, durationMs 0/0 in both sources); core rebuilt (dist/index.d.ts mtime 1788710748 to 1788711137); BASE dist durationMs?=0, duration?=0, getPluginStartupDurations=0; restore by git checkout HEAD -- both paths, blobs d541278af / a368dca10 equal to HEAD, git diff HEAD empty, rebuilt (mtime 1788711148) byte-identical to the pre-ablation head dist by cmp; base-to-head declaration delta 38 lines (durationMs?: number; getPluginStartupDurations(); the private bare-name line pluginStartTimes to pluginStartupDurations); exported-name sets 260 to 260, added none, removed none, firing control PluginStartupResult and ObjectKernel present — a first extractor returned 0/0 on both sides and was discarded as not live. check:duration-unit-keys (absent on the branch; run from a fresh origin/main worktree at f377394ae with checker blobs verified equal): on origin/main exit 0 — '✓ check:duration-unit-keys — 215 duration-shaped numeric key(s) across 2326 source file(s) all carry their unit in the key name (or in a sibling unit, or under a declared exemption: 6 declared EpochMs instant(s), 11 declared externalVocabulary mirror(s)); zero offenders, no baseline.'; --root on my tree exit 1 with 71 offenders — 70 packages/spec, 1 packages/drivers/driver-turso, 0 packages/core — all merge-base-era spellings of files this PR does not touch (0 spec files in the diff) and already renamed on main; --list --root on my tree: 0 rows from packages/core, 0 from plugin-loader.ts, control 8 rows from startup-orchestrator.zod.ts — the gate's population is numeric Zod chains and excludes the TS interface, so its green is stated and not offered as proof (PM correction confirmed by measurement). Scratch merge tree 518c8677c (4458bcd49 merged with f377394ae, 0 conflicts, the three core files byte-equal to the branch head, spec schema byte-equal to main, never pushed, closure built under the lock 4m08s): check:duration-unit-keys exit 0 with the same verdict line; core test 'Test Files 50 passed (50) / Tests 1217 passed (1217)', typecheck 'check:test-typecheck: OK'; dispatch-gates (origin/main's script, --repo objectstack-ai/objectstack, the six pinned diff paths from git diff --name-only e1d4f9e3f 4458bcd49 — the no-path form derives from the script's own tree and exited 2 there) derived 54 commands: 52 exit 0, 2 exit 3 = PREREQUISITE NOT MET, not measured (check:dual-build-cjs-loads: 76 packages unbuilt there, 'This is NOT a pass: nothing was measured'; check:type-check-debt --re-measure: stale tier itemisation on @objectstack/spec-monorepo, a maintainer's act, its coverage half 'OK — 75/79 workspace packages type-checked'); check:dts-closure swept the 4 built packages (core, metadata-core, spec, types are the only dist directories in that tree) '41/41 declared declaration file(s) present'; the 6 families marked NOT RUNNABLE LOCALLY ($RUNNER_TEMP, matrix shards) are CI's. Pair checker: baseline exit 4 — MALFORMED 'Clause-②: ⚠️ **expected yes** ...'; after the claim-comment edit exit 0 — '✓ check-clause2-carriers: PR #16057 / card #15820 — the clause-② declaration is readable in the fixed spelling and both carriers agree.' Line-number anchors into kernel.ts / plugin-loader.ts outside packages/core: 2 hits, both docs/audits history files (kernel.ts:87, :455); kernel.ts line count unchanged (861), plugin-loader.ts 514 to 515 with 0 anchors; control on the same grep shape 14. CI at 4458bcd49 read at 16:3xZ: 33 runs, 30 success + 3 skipped, 0 failing.", "mcp_calls": "0 — every GitHub read and write went through REST (curl via the agent proxy; the pair checker with NODE_USE_ENV_PROXY=1); no MCP GitHub call was made", "open_questions": [], "out_of_scope_findings": [ "not filed: check:type-check-debt --re-measure exits 3 on the origin/main-merged tree — a stale tier itemisation on @objectstack/spec-monorepo (tallied 80, recorded 26); the gate's own output already tracks it as #10722 and calls it a maintainer's re-tally, so no new card" ] }
Generated by Claude Code
Round record — the contract-review FAIL is discharged; PR #16057 is green at a new head and a re-review is owed
domain:enginedispatching seat, 2026-09-06T16:5xZ. Verdict5555409410(bound to head4d20aa70) discharged by commit4458bcd49. ⭐ That verdict is void by construction — a clear binds to a head — and the round said so on the PR rather than leaving it ambiguous.needs:contract-reviewstays on both carriers, PR stays draft, nothing released. At4458bcd4: 36/36 completed, 0 failing,legacy: success,ms=clean.What was owed, and what was measured
duration→durationMsonPluginStartupResult, bothkernel.tsemit sites, both pin tests. Verified in the built artefact rather than the source:dist/index.d.tsand.d.ctseach carrydurationMs?: number1×,duration?: number0×, and the retired citation 0×.⭐ The false provenance was wider than the verdict said. The verdict named the published
.d.tsJSDoc; the round found the same retired citation in four places — the interface JSDoc,kernel.ts's{@link}, the changeset, and the PR title/body — and corrected all four. A sentence that ships inside a published declaration is the one that matters most, and it was one of the four.⭐ Two corrections the round made to things it was handed
- To my Zone 1.2, which asked what the tombstone requires of core: nothing. The tombstone entry's own text records that core's
PluginStartupResultis a different type and not a reader of the schema. ⇒ no ADR-0087 treatment on the core side; core simply must not adopt the retired spelling. ⭐ It read the entry rather than inferring a requirement from the word "tombstone". - To the verdict's own citation: the entry lives at
packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__duration.ts, ⛔ not the bareretired-keys/path the verdict spelled.
⭐⭐ And it measured my gate correction instead of accepting it
I told the round mid-flight that
check-duration-unit-keyscannot see a TS interface, so its green is not evidence. It verified:--list --rootreturns 0 rows frompackages/core/, 0 fromplugin-loader.ts, against a firing control of 8 rows fromstartup-orchestrator.zod.ts. ⇒ "its green is stated and not offered as proof; the diff and the built declaration are." Exactly right — and it is the difference between taking a correction and establishing it.⚠️ Stated rather than hidden: on the branch's own tree that gate exits 1 with 71 offenders (70 inpackages/spec, 1 indriver-turso, 0 inpackages/core) — merge-base-era spellings of files this PR does not touch, already renamed onmain.⭐ A discarded instrument, and a refusal to over-claim
- The first name-set extractor returned 0/0 on both sides and was discarded as not live rather than banked as "no delta". That is the rule this seat learned the hard way today, applied unprompted.
- On CI the round refused a conclusion it had not earned: at
4458bcd49it read "33 runs: 30 success + 3 skipped, 0 failing — three fewer runs than the prior head at that moment, so not yet acompleted == totalstatement about the same set; convergence is the PM's." ⭐ A count comparison across two heads is not condition ③. (It has since converged: 36/36.)
The half-written pair, repaired
--pair 16057went exit 4 → exit 0: the claim comment'sClause-②: ⚠️ **expected yes** …was never edited to the bareClause-②: yesthe checker parses. ⭐ That defect was invisible to me and to the original verdict; the fable review found it.Clause-② re-measured at the new head by dist ablation with both legs proven on disk: exported names 260 → 260 while limb 1 fires (
durationMs?: number,getPluginStartupDurations()) and limb 2 does not ⇒Clause-②: yes, unchanged — read in the ruled limb language of #16229, not by eyeballing bytes.State
⛔ A re-review is owed on head
4458bcd4. The prior verdict is void; the PR is green and complete on its own terms and waits, with four siblings, on the one question that is not mine to answer.
Generated by Claude Code
- To my Zone 1.2, which asked what the tombstone requires of core: nothing. The tombstone entry's own text records that core's
Landed and closed — verified by content on
origin/main, and the controls happen to prove the review's central claim toodomain:enginedispatching seat, sessionsession_01ARYe3yQTQCUFm5qPYNgKaJ, 2026-09-06T23:2xZ. PR #16057 merged; this card auto-closed on itsFixes #15820.pm:dispatchedstripped in this stroke (read-modify-write, compare read-back exact) — the round is over.The landing, established by content rather than by the API's
mergedfieldorigin/main=66dc6ab25. Two probes of different kinds, each against a control that exists onmainindependently of the change:probe reading firing control durationMsinpackages/core/src/plugin-loader.ts4 startTime= 6getPluginStartupDurationsinpackages/core/src/kernel.ts3 getPluginMetrics= 1⭐ Those two controls are not only controls.
startTimeandgetPluginMetricsare the old spellings, and finding them alive onorigin/mainis the same reading that says limb 2 did not fire — nothing that compiled before this landed stops compiling. The check that proves the probe channel is live is the check that proves the change was non-breaking, which is a pleasant accident worth naming rather than passing over.What this card ends up having settled
The rename owed by the human contract-review verdict
5555409410(bound to the then-head4d20aa70) was discharged at4458bcd49, and the replacement verdict5562594464— reviewed atCONTRACT_REVIEW_TIER, every citation re-verified against the tree by this seat — recorded limb 1 fires additively, limb 2 does not, on a three-legdistablation rather than on a name-set count that would have read 260 → 260 and said nothing.Two corrections this card produced, both worth more than the fix itself:
- ⛔
check-duration-unit-keysis structurally blind to a plain TS interface. I had named the wrong path for it (scripts/pm/check-duration-unit-keys.mjsdoes not exist; it ispackages/spec/scripts/check-duration-unit-keys.ts, and it lives only onorigin/main). The blindness itself was then re-earned properly:--listgives 0 rows frompackages/core/against 8 fromstartup-orchestrator.zod.ts, and a--rootablation gives a copy ofplugin-loader.ts0 rows while a zod positive control gives 2 rows / 1 offender. ⇒ its green was never evidence here. ⚠️ ADR-0087 L1 was invoked by outcome, not mechanism.docs/adr/0087…:31defines L1 as a spec-side conversion layer; this change has no spec shape and no conversion. It borrows L1's outcome ("no consumer action at all"). The limb-2 conclusion rests on the measurement above, ⛔ not on the label.
One note carried, non-blocking: the JSDoc that ships inside
dist/index.d.tscites the spec'sdurationMskey and its retireddurationtombstone — false on this PR's own tree, true on the merge target, andgit merge-treeconfirmed the PR merged clean, so the published text is true at release. Now that it has landed, that is simply true.
Generated by Claude Code
- ⛔
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.