spec: the genuine duration rows declare their unit — DurationMs/DurationSeconds and two externalVocabulary mirrors - #18657
Conversation
…s/DurationSeconds and two externalVocabulary mirrors Step 3 of ruling A on #18115: eight of the ten census rows now declare their unit through a channel the reader reaches. Two rows are reported back rather than converted. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…the two mirrors Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 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 384a3a19bf0a6e5fd8deb01c8dc121a2267ebd9f && git checkout 384a3a19bf0a6e5fd8deb01c8dc121a2267ebd9f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 99fcb4ac104d44625136df690b51a2ff30a99d7c 43b24d4c7086d12d0f78046b2f08fdc2385d677b && git checkout -B drift-repro 99fcb4ac104d44625136df690b51a2ff30a99d7c && git merge --no-ff 43b24d4c7086d12d0f78046b2f08fdc2385d677b
node scripts/docs-audit/affected-docs.mjs --json 99fcb4ac104d44625136df690b51a2ff30a99d7c
|
|
落地前检 + 受管面裁定 — 🚨 正文那条受管面结论被实测推翻:本 PR 走普通队列落地正文写:「 判据链,逐条:
落地前检三条① 席内条款②复核 —— 不欠。条款②的定义是「放宽接受集或扩大公开面」,而 SKILL.md 同处写明「拉回已声明契约不触它」。本 PR 的方向是收窄:三个原本 ② 双载体机读 —— exit 0。 ③ CI 全绿 —— 35 个 check-run:33 success · 2 skipped · 0 其它,且
|
| 读数 | 值 |
|---|---|
| 分支改过、且 main 在窗口内也改过的 os-regen 路径(驱动器唯一能动手的集合) | 0 |
| main 自合并基以来动过的 os-regen 路径(全部,不限本分支) | 0 |
| ⭐ 亮控 — main 自合并基以来的提交数 | 6 |
⭐ 亮控 — main 自合并基以来动过的 packages/spec/src 文件数 |
2(contracts/automation-service.ts + 其 test,来自 #18635) |
⇒ 静默吞边的危险按构造不存在:git 只在两侧都改过一条路径时才调驱动器,而那个集合读 0。两条亮控都非零,所以这个 0 ⛔ 不是「窗口里什么都没动」的平凡真 —— 本班上一次就栽在亮控自身为零上,这次先验了。⇒ 直接入队;落地后仍按纪律重验生成物(队列自己的 merge 也跑同一个驱动器)。
Docs Drift 咨询已作答,读数为零
机器人点名 content/docs/protocol/kernel/error-handling.mdx(经由 BaseResponseSchema)。逐字查:该页对 BaseResponseSchema 只有一处链接,全篇 0 处提 duration、0 处陈述任何单位 ⇒ 本 PR 没有让它变假。咨询已答,⛔ 无需人工复读。
⚠️ 一条如实记录的档位事实(⛔ 不掩,也 ⛔ 不当作入队闸)
本卡认领时的文件面读回 no path-derived mandate(逐字:the surface hits none of the 3 declared glob(s)),⇒ Container & model: model: default judgement tier 在认领那一刻是对的。
但实际 diff 的文件面读回:
Model tier — MANDATORY: claude-fable-5-1 (derived from the file surface, not recalled).
- skills/objectstack-api/references/_index.md ⇢ 'skills/**' — clause ①
… and one such path closes the exit for the whole card
差别整个来自认领评论里那条预先申报的开放臂:「任何门禁反向要求的派生物」。生成器把一行写进了 skills/**,于是实际面带上了一条强制 fable 的路径,而派发是按默认档跑的。
本席的判断,连同它的边界:这一行是生成器自己 --check 按字节认证的纯重生成,clause ① 要防的危险(手写错的 skill 内容原样发运给第三方)在这里结构性不存在;fable 档的判断力 ⛔ 加不到一个字节相等证明上。⇒ 不重派、不阻队。但这条「预申报的派生物臂能把 skills/** 悄悄拉进面、从而同时欠一个档位与一份净增行数预算(SKILL.md 546)」是协议层的真缺口,本席另立卡,⛔ 不在本 PR 处理。
净增行数本身 dev 已经量了并且很小:改动文件 50 → 51 行(+1,生成);整包每个 SKILL.md 之和 6145 → 6145(+0);check-skills-token-ratchet exit 0。
下一步
转 ready + 入队。Part of #18124、全文零闭合关键词 ⇒ 合并 ⛔ 不关卡;落地后由本席摘 pm:dispatched、把 #18124 放回队列并写清「交付八行、剩两行各欠什么裁决」。
Generated by Claude Code
Part of #18124
Clause-②: no
Step ③ of ruling A on #18115 (decision batch #134 item 1). Eight of the census's ten genuine-duration rows now declare their unit through a channel a reader actually reaches. Two rows are reported back to the director seat instead of converted, each with the measurement that stopped it.
Part of, not a closing keyword, and deliberately: two ruled rows are not delivered here, so a merge must not retire the card. The director seat decides when it closes. The two are named under "Reported back" below.The blocker cleared — proved by subject probe, not by card state
Card #18123 (step ②) is open, but its SUBJECT is on
main. Commit8ca7aafc45— spec(gate): check-duration-unit-keys admits by declaration — retire the name-shape token list, PR #18486 — carriesPart of #18123rather than a closing keyword, so the behaviour shipped while the card stayed open. On this branch's merge base922c75588,DURATION_ROOTSinpackages/spec/scripts/check-duration-unit-keys.tsalready maps bothDurationMsandDurationSeconds.That answers the dispatch's open question — does the gate stop seeing a row that adopts
DurationMswhile its key name carries no unit, or start refusing it? — with a third answer: neither.--listexternalVocabularymirrorsEpochMsinstantsdimensionlessA converted row is admitted by its TYPE (or by its mirror marker) and exempted from the key-NAME requirement; both contradiction directions stay refusable. Nothing is refused that was not refused before.
The population, re-derived by key + file, with the unit MEASURED per row
The card's "likely unit" column carries its own warning and was not copied. Each row's producer was measured; where the measurement came back EMPTY that is stated as such rather than dressed up.
api/contract.zod.tsBaseResponse.meta.durationpackages/rest/srccontains notimestamp:writer at all, so the envelope'smetablock is never emitted; a repo-wide sweep ofduration:assignments in non-test sources returns onlyDate.now() - startsites (cli/src/utils/config.ts,core/src/qa/runner.ts,timer.elapsed()=Date.now() - start). Declared from the sibling channel instead: every spec key that spells a processing time spells ms (tracing.durationMs,worker.durationMs,worker.avgExecutionMs), and the schema's own test literal is150.DurationMskernel/plugin-lifecycle-advanced.zod.tsHotReloadConfig.shutdownTimeoutpackages/core/src/hot-reload.tsis the only in-repo reader and treats it as a millisecond budget; its suite drives120_000,1000and50through it. The #15676 wave recorded in the migration ledger that this sibling is "deliberately NOT renamed" withdebounceDelayMs, so the type route is the one the repo already chose.DurationMskernel/plugin-security-advanced.zod.tsKernelSecurityPolicy.cors.maxAgeAccess-Control-Max-Ageheader. Its TWIN —shared/http.zod.tsCorsConfig.maxAge— is the same field and already declares.meta({ externalVocabulary: 'CORS ...' })with the describe "Preflight cache duration in seconds". The live emitter,packages/adapters/hono/src/index.ts, readsOS_CORS_MAX_AGEwith a default of86400and hands it to the header.externalVocabularysystem/auth-config.zod.tsAuthConfig.session.updateAgepackages/plugins/plugin-auth/src/auth-manager.tsforwards it by name: `updateAge: this.config.session?.updateAgesystem/metrics.zod.tsMetricAggregationConfig.window.slideIntervaldurationSeconds, and the schema's own fixture pairsdurationSeconds: 300withslideInterval: 60.DurationSecondssystem/metrics.zod.tsMetricsConfig.retention.downsampling[].resolution(class B)afterSeconds. The unit was in the JSDoc alone — the #14519 shape, invisible on the published page.DurationSecondssystem/metadata-persistence.zod.tsMetadataLoadResult.loadTime(class D)metadata/src/loaders/filesystem-loader.tsanddatabase-loader.tswriteDate.now() - startTime;memory-loader.tsandremote-loader.tswrite literal0.DurationMssystem/metadata-persistence.zod.tsMetadataSaveResult.saveTime(class D)DurationMsClass B's second row and one class A-true row are not converted — see below.
Why two rows took
externalVocabularyand not a typeThe dispatch left this to measurement. For
cors.maxAgethe decisive reading is that an identical key already exists in this spec and already carries the marker; giving the twin a different declaration would make one field read two ways. ForupdateAgethe decisive reading is the schema's own comment, which describes theexpiresIn/updateAgePAIR as better-auth names forwarded verbatim while only one of the two carried the marker.Neither route changes the accepted set, so this does not move the declaration:
Clause-②: nostands either way.Reported back rather than converted — ⛔ both are correct outcomes, not gaps
1.
data/field-value.zod.tsFileValue.duration— the unit is not measurable, and both closed types are hazardous here.ffprobe, no probe of any kind;ObjectQLEngine.resolveFileReferencesexpands a file reference to{ id, name, size, mimeType, url }and never writesduration.durationhits are Tailwind transition classes).content/docs/protocol/objectql/types.mdxnamesdurationas an optional member and states no unit.HTMLMediaElement.duration,ffprobe— is fractional seconds.DurationSecondsis.int(), so adopting it would refuse12.34;DurationMswould contradict that convention by a factor of 1000.FileValueSchemais the pre-v17 inline blob that ADR-0104's dual-mode window keeps parsing, so.int()narrows against rows that may already exist — a different risk class from the rest of this tranche.Three ways forward, all outside this card's fences: accept integral seconds and its truncation; add a fractional-seconds unit to the vocabulary (⛔ "no new type"); or rename (⛔ the rename branch). The director seat sequences it.
2.
kernel/plugin-versioning.zod.tsCompatibilityMatrixEntry.estimatedMigrationTime— the unit is HOURS, which no type in the closed vocabulary covers.Its JSDoc reads "Estimated migration time in hours" and the schema's own fixtures are
8and40. There is no runtime producer or consumer.DurationMs/DurationSecondscannot carry hours, the card fences a new type, and the remaining route — moving the unit into the describe — lands the row straight onunit-in-prose-not-in-name, whose prescription is a RENAME of a published authorable key (kernel/CompatibilityMatrixEntry:estimatedMigrationTimeis on the authorable surface). That owes an ADR-0087 entry inpackages/spec/src/migrations/registry.ts, a file held by seat 1's in-flight card. ⛔ Not opened.Red before green
The gate that reverse-requires this change is
check:docs(withcheck:skill-refsbeside it). Before regeneration, on the converted sources:After
check:generated --fix:✓ All 15 generated artifacts are up to date.check:duration-unit-keysitself cannot go red for an unconverted row, and that is by design, not an oversight: a key that declares nothing is not admitted to the census, so there is no verdict to fail. What CAN go red is the declaration once it exists — both routes, proved by ablation from the committed state, each leg with an on-disk proof and a hash-verified restore:Leg B is the stronger reading of the two: it shows the mirror route is also load-bearing, which this author expected NOT to be the case. Removing the marker does not make the key vanish from the census, because the describe still names the unit — so the gate refuses it.
Controls
⭐ LIT — already-declared siblings, unchanged and still reading as declared in
--listafter:⭐ DARK — things that must read zero, and do:
EpochMsinstants 15 → 15 anddimensionless0 → 0: the census's deliberately-excluded classes (the 10 instants, the 17 dimensionless rows) did not move.check:authorable-surfacegreen with no regeneration: not one authorable key was added, removed or renamed.Verification
pnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec check:generatedpnpm --filter @objectstack/spec check:authorable-surfacepnpm --filter @objectstack/spec check:api-surfacepnpm --filter @objectstack/spec check:docspnpm --filter @objectstack/spec typechecktsc --noEmit+ scripts + test layer)pnpm --filter @objectstack/spec testtsx scripts/check-duration-unit-keys.ts --listbefore / afterpnpm --filter @objectstack/spec check:duration-unit-keysnode scripts/pm/check-widening-tells.mjs --declaration no --diff ...node scripts/check-adr-0087-registration.mjs --base origin/mainnot-required (no-migration-prescription)pnpm check:nul-bytesNOT MEASURED, stated as such:
pnpm --filter @objectstack/spec check:skill-examples— refuses to run without@objectstack/client-reactbuilt (a prerequisite this branch did not build). Not a red on this diff; CI builds it.dispatch-gates.mjs --ranreconciles 33 of 110 derived families. The remaining 77 are the farm, and are CI's — this is a declared narrowing, not a silent one.pnpm check:cross-package-test-inputsexits 1 on this tree, and it is not this diff: the finding is thatpackages/cli/test/init-created-files-summary.e2e.test.tsdescendspackages/spec/dist/with no declared glob reaching inside it. None of this branch's 29 paths touchespackages/cli/**, the declaration table, or that gate's script — and that script is the subject of a separate in-flight PR.Skill-surface readings (this diff touches a published skill path)
The only
skills/**change is one generated line inskills/objectstack-api/references/_index.md, written bygen:skill-refsbecauseshared/duration.zod.tsnow has an importer inside the API skill's reachable set.SKILL.md, before → after: 6145 → 6145 lines (+0)node scripts/check-skills-token-ratchet.mjs: exit 05714917300)。 原文写「skills/**是受管面,所以本 PR 不走合并队列、等维护者点头」。node scripts/pm/check-governed-merges.mjs --pr 18657在装了依赖的 checkout 上读回✅ NOT governed — ordinary queue landing applies:该行被 #11705 生成物例外(维护者 2026-08-25 取 A)按字节抬起 —— 它与pnpm --filter @objectstack/spec gen:skill-refs在本树现算的输出完全相等,且本 PR 的 29 条路径一条都没碰packages/spec/scripts/(#11084 共编栅栏未触发)。CI 的Governed Surface Queue Guard同向读 success。⇒ 本 PR 走普通队列落地,⛔ 不等人批。Acceptance notes
MetricsConfig.retention.downsampling[].resolutiongains its unit in the JSON Schema (json-schema/system/MetricsConfig.json) but not on the rendered reference page:build-docs.tsemits a nested table for an object-valued member (which is whyKernelSecurityPolicy.corsgains one here) and does not for an ARRAY-of-object member, sodownsamplingstays a one-line type signature. The declaration is correct and reaches the JSON Schema; the page is one generator behaviour short of showing it. Noted, not filed.RestServerConfig.responseFormat.includeMetadataandPluginRestApiConfig.includeMetadataboth declare "include response metadata (timestamp, requestId)", and nothing inpackages/rest/srcwrites that block — the same absence that mademeta.durationunmeasurable. Declared, unimplemented. Noted, not filed.File surface
Every path this branch touches:
From the fenced OPEN set: nothing.
packages/spec/scripts/check-duration-unit-keys.ts,packages/spec/src/migrations/**,packages/spec/src/contracts/automation-service.ts,packages/services/service-automation/**,packages/types/src/env.ts,content/docs/automation/flows.mdxandscripts/check-cross-package-test-inputs.mjsare all untouched.packages/spec/src/api/contract.zod.tsIS touched and is the file that is mine; the similarly-namedcontracts/automation-service.tsis not.维护者速读(草稿)
改了什么。 规格里 8 个「真时长」字段现在把自己的单位写在了合约上:6 个改用闭合类型
DurationMs/DurationSeconds,2 个挂externalVocabulary标记(它们的键名来自外部标准,改名会切断与标准的一一对应)。键名一个都没改,也没有增删任何可写的键。为什么改。 一个
duration: number字段,单位只写在代码注释里、或者哪里都没写,作者(很多时候是 AI)照着邻居的数字抄一个过来,就是一次静默的 ×1000 错误——缓存从 1 小时变成 3.6 秒,谁也不会收到报错。这一批把单位放进类型和发布出去的 JSON Schema / 参考页,让读文档的人和写元数据的人看到同一个答案。风险与代价(含回滚)。 三个原本写作⚠️
z.number()的字段现在只接受非负整数(meta.duration、loadTime、saveTime)。本仓库里测到的每一个写入方都已经在写整数,所以实测风险为零;真有人写小数,会在写的那一刻响亮报错,而不是悄悄算错。回滚就是 revert 本 PR,没有数据迁移、没有台账条目、没有墓碑。本 PR 动到—— 此句已被实测推翻(评论skills/**,属受管面,不走合并队列5714917300):那一行是生成器自己--check按字节认证的纯重生成,受 #11705 例外,照常走合并队列。席位意见。(留空)
你要做的。 ① 确认两个「退回」的行由谁排期:
FileValue.duration的单位在本仓库测不出来(没有任何生产者),且两个闭合类型都有风险;estimatedMigrationTime的单位是「小时」,现有类型覆盖不到,而改名会动到被别人占用的台账文件。②确认—— 这一条不用你答了:仪器已答(评论skills/**那一行生成内容可以随本 PR 一起进5714917300),按 #11705 例外自动随本 PR 进。⇒ 只剩 ① 需要你的字。🤖 Generated with Claude Code
https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Generated by Claude Code