Found while inventorying duration-shaped declarations in packages/spec for #13801. Out of scope there: #13801's misread is the clock (calendar vs business hours); this one is the magnitude (seconds vs milliseconds), a different misread on the same key population.
What was measured
178 .describe() strings in packages/spec/src/** name a time unit (git grep -iE over describe text for hours/minutes/days/duration/timeout/interval/maxAge/expireAfter/seconds/milliseconds/ms, tests excluded). For most of them the unit is also in the key name (timeoutMs, timeoutHours, retentionDays, expireAfterSeconds) — the author sees the unit at the moment of typing the number. Three shapes do not, and each is the kind of drift a gate could hold:
- Two
ttl keys, two units, one block. packages/spec/src/kernel/metadata-loader.zod.ts:82 cache.ttl — 'Cache TTL in seconds' (default 3600); :96 cache.databaseLoader.ttl — 'Cache TTL in milliseconds' (default 60_000). Same key name fourteen lines apart, unit differs by 1000×; the reader packages/metadata/src/loaders/database-loader.ts:204 (ttl: cacheOpts?.ttl ?? 60_000) honours the inner one in ms.
- Bare
timeout keys whose unit lives only in prose. packages/spec/src/data/hook.zod.ts:306 (Hook.timeout, ms — reader packages/objectql/src/hook-wrappers.ts:357), packages/spec/src/system/job.zod.ts:142 (Job.timeout, ms — reader packages/services/service-job/src/run-with-policy.ts:127), packages/spec/src/data/driver.zod.ts:23 (DriverOptions.timeout, ms). Sibling keys in the same schemas spell it (backoffMs, intervalMs, timeoutMs on ScriptBody), so the population already carries two conventions.
- Describes that name no unit at all.
packages/spec/src/system/tenant.zod.ts:552 idleTimeout — 'Idle pool timeout' (JSDoc says seconds, default 300); :672 sessionTimeout — 'Session timeout' (JSDoc says seconds, default 3600). Both are on the authorable surface; both describes are what content/docs/references/** publishes.
Existing precedent for a value-carried unit: LIFECYCLE_DURATION_REGEX (packages/spec/src/data/object.zod.ts:828, '14d' literals) and the { value, unit } pairs in packages/spec/src/system/disaster-recovery.zod.ts:146/162.
Candidate gate (observation, not a decision)
A spec-source gate in packages/spec/scripts/ of the shape "a z.number() key whose .describe() names a time unit must carry that unit in the key name (Ms|Seconds|Minutes|Hours|Days), or the schema must use a unit-carrying value (LIFECYCLE_DURATION_REGEX / {value, unit})". Renaming the existing offenders is an ADR-0087 conversion each (published keys); the gate can ratchet from today's count. Whether this is worth a card at all is a triage call — the population is stable and the readers are correct today; the risk is the next key added without a suffix.
Refs #13801.
Triage — re-measured at ed44512, split, and graded
Every cited line reproduces exactly. I read all eight sites rather than accepting the inventory: metadata-loader.zod.ts:82 / :96 (and the JSDoc at :91 which itself says "honored within ttl milliseconds", so the file disambiguates in prose twice while the two same-named keys stay 1000× apart), database-loader.ts:204, hook.zod.ts:306, job.zod.ts:142, driver.zod.ts:23, tenant.zod.ts:552 / :672. Nothing in the card is overstated.
Answering the card's own question — "whether this is worth a card at all is a triage call": yes, but as two cards, not one.
Shape 3 needs no ruling. The .describe() is what the docs publish and the JSDoc is not, so those two keys publish a bare number with no unit while their own source carries it one line above. Adding the unit to two strings changes no accepted value and widens nothing. That is now #14519, pm:queue, dispatchable today.
Shapes 1 and 2 plus the gate stay here, because every route through them is above the seat: renaming published keys is an ADR-0087 conversion each, and standing up a new spec-source gate is governance. Direction is the maintainer's.
Routing
domain:spec — packages/spec is one contract lane whole, and a gate under packages/spec/scripts/ orbits the spec contract rather than general dev tooling (lane table's spec/devx split). priority:p3: zero measured pull — every reader is correct today, by this card's own measurement — and the exposure is the next key added, not any key that exists. needs-user-decision.
<!-- os-decision-facets -->
- ① 项目长远合理性(权重 ≥50%,领起推荐) —— 今天平台里同时活着三种表达「一段时间」的办法:单位写在键名里(
timeoutMs)、单位只写在描述里(timeout / ttl)、单位跟着值走('14d'、{value, unit})。三种并存本身就是契约增生。长远终态应当只有一种,而且是读的人不用翻文档就知道的那种。加一道门禁把新键钉死在这条线上 = 缩小特例;把存量全部改名 = 同一个终态早到几个月,代价是每个已发布键一次破坏性转换。
- ② 实际业务拉动 —— 今天零事故,而且是量过的:所有读取方都读对了(
database-loader.ts:204 按毫秒读内层 ttl,正确)。没有客户在撞这件事。零拉动。按分歧推荐序,零拉动⇒荐④不扩散 —— 不为一个没人撞上的问题做批量改名。
- ③ 防 AI 犯错 —— 出错时谁看到什么:一个作者(人或 AI)看到外层
ttl: 3600 就照抄进内层 ttl,那里按毫秒算,一小时于是变成 3.6 秒。不报错、不告警,缓存只是一直是冷的,谁也不会去查。把单位钉进键名或值 = 抄错时名字本身就对不上,是写代码当场的响亮拒绝;留在描述里 = 静默错 1000 倍。这一棱最响,而且它要的是「下一个键不会错」,门禁正好只管下一个。
- ④ 创业阶段不扩散 —— 每个已声明的键都是永久义务。现在立门禁,新键从今天起只有一种写法,往后每年都省;现在批量改名,是一次性的破坏性转换,而且要同批更新文档、迁移指引和所有下游配置。ratchet 优于 rename-everything。
推荐:A —— 只加门禁,从今天的计数棘轮(ratchet),存量全部计入基线不动。 新增的时长型 z.number() 键必须单位在键名或在值里。①(收敛到一种写法)与④(不做批量破坏性改名)同向,②零拉动支持不动存量,③要的保护由门禁全额提供。
回退:B —— 门禁 + 分批把存量按 ADR-0087 转换掉,从两个 ttl 撞名那一对开刀(全仓唯一一处同名不同单位)。若维护者判定「同名不同单位」本身就是必须现在拆掉的雷,走这条。
置信缺口(本分析看不见什么): 看不见这三种写法在 cloud 仓和真实客户配置里的分布 —— 本轮量到的是 spec 侧的声明面,不是配置面,所以「零事故」这个读数只覆盖本仓的读取方。另外门禁该落在 packages/spec/scripts/ 还是 packages/lint,本席不判,那是拿到方向后由 domain:spec 席位定的落点,不影响方向选择。
⚙️ Epic delegation — subtree owned by session_01G4138K1EG7kQ81FNba5Kp4 (os-sales) from 2026-09-05T07:53Z
Delegated under the maintainer's direct-dispatch channel, authorised in-session: 「你继续负责跟进这个任务到合并」, 「15626你应该负责修复红啊」, 「我驱动整条栈」, 「用epic 卡派发」. Rationale and the measurements behind it: seat post #6017 comment 5550404209.
⛔ Other PMs: skip this whole subtree when acquiring candidates. The domain:spec seat (session_01M59rPZZFzqhfMUPFqqZTkf) still holds post #6017 and the rest of the lane — ⛔ only this subtree moved.
Sub-issues (the epic queue, re-read each round, never cached): #15676 (1/6, claimed, dispatching) · #15677 · #15678 · #15679 · #15680 · #15682 — the last five claimed and held at pm:blocked, which is their true state: they are defined against what remains after 1/6 declares the exemption classes.
Declared file territory (narrow on purpose — a wider claim would be less honest, not safer):
packages/spec/src/** — the gate's declared population, where the conversions land
packages/spec/scripts/check-duration-unit-keys.ts — the gate itself (1/6 teaches it the two exemptions; 6/6 widens its population)
packages/spec/src/migrations/** — ADR-0087 registry entries per renamed key
- the readers each rename touches, per card, under
packages/{objectql,metadata,runtime,rest,services,platform-objects}/**
- ⛔ NOT claimed:
skills/**, docs/adr/**, .claude/**, content/docs/releases/, and every card outside this subtree.
Sequencing — the domain:spec seat's, not mine, quoted from #15676: "branches stacked on #15626's; the set lands together when check:duration-unit-keys is green on the stacked head", and PR #15626 "stays a draft (governed — skills/** — human merge) and is ⛔ not landed with the gate red or unwired".
⚠️ The gate's own predicate is the population's definition — run pnpm --filter @objectstack/spec check:duration-unit-keys on the stacked head. ⛔ Nobody recounts by another rule. Baseline: 65 offenders / 215 duration-shaped keys / 762 files on PR #15626 head 140e0b266, unchanged by the 63-commit merge.
⇒ If the domain:spec seat wakes, it says so on #6017 and the subtree goes straight back.
Generated by Claude Code
Found while inventorying duration-shaped declarations in
packages/specfor #13801. Out of scope there: #13801's misread is the clock (calendar vs business hours); this one is the magnitude (seconds vs milliseconds), a different misread on the same key population.What was measured
178
.describe()strings inpackages/spec/src/**name a time unit (git grep -iEover describe text for hours/minutes/days/duration/timeout/interval/maxAge/expireAfter/seconds/milliseconds/ms, tests excluded). For most of them the unit is also in the key name (timeoutMs,timeoutHours,retentionDays,expireAfterSeconds) — the author sees the unit at the moment of typing the number. Three shapes do not, and each is the kind of drift a gate could hold:ttlkeys, two units, one block.packages/spec/src/kernel/metadata-loader.zod.ts:82cache.ttl—'Cache TTL in seconds'(default 3600);:96cache.databaseLoader.ttl—'Cache TTL in milliseconds'(default 60_000). Same key name fourteen lines apart, unit differs by 1000×; the readerpackages/metadata/src/loaders/database-loader.ts:204(ttl: cacheOpts?.ttl ?? 60_000) honours the inner one in ms.timeoutkeys whose unit lives only in prose.packages/spec/src/data/hook.zod.ts:306(Hook.timeout, ms — readerpackages/objectql/src/hook-wrappers.ts:357),packages/spec/src/system/job.zod.ts:142(Job.timeout, ms — readerpackages/services/service-job/src/run-with-policy.ts:127),packages/spec/src/data/driver.zod.ts:23(DriverOptions.timeout, ms). Sibling keys in the same schemas spell it (backoffMs,intervalMs,timeoutMsonScriptBody), so the population already carries two conventions.packages/spec/src/system/tenant.zod.ts:552idleTimeout—'Idle pool timeout'(JSDoc says seconds, default 300);:672sessionTimeout—'Session timeout'(JSDoc says seconds, default 3600). Both are on the authorable surface; both describes are whatcontent/docs/references/**publishes.Existing precedent for a value-carried unit:
LIFECYCLE_DURATION_REGEX(packages/spec/src/data/object.zod.ts:828,'14d'literals) and the{ value, unit }pairs inpackages/spec/src/system/disaster-recovery.zod.ts:146/162.Candidate gate (observation, not a decision)
A spec-source gate in
packages/spec/scripts/of the shape "az.number()key whose.describe()names a time unit must carry that unit in the key name (Ms|Seconds|Minutes|Hours|Days), or the schema must use a unit-carrying value (LIFECYCLE_DURATION_REGEX/{value, unit})". Renaming the existing offenders is an ADR-0087 conversion each (published keys); the gate can ratchet from today's count. Whether this is worth a card at all is a triage call — the population is stable and the readers are correct today; the risk is the next key added without a suffix.Refs #13801.
Triage — re-measured at
ed44512, split, and gradedEvery cited line reproduces exactly. I read all eight sites rather than accepting the inventory:
metadata-loader.zod.ts:82/:96(and the JSDoc at:91which itself says "honored withinttlmilliseconds", so the file disambiguates in prose twice while the two same-named keys stay 1000× apart),database-loader.ts:204,hook.zod.ts:306,job.zod.ts:142,driver.zod.ts:23,tenant.zod.ts:552/:672. Nothing in the card is overstated.Answering the card's own question — "whether this is worth a card at all is a triage call": yes, but as two cards, not one.
Shape 3 needs no ruling. The
.describe()is what the docs publish and the JSDoc is not, so those two keys publish a bare number with no unit while their own source carries it one line above. Adding the unit to two strings changes no accepted value and widens nothing. That is now #14519,pm:queue, dispatchable today.Shapes 1 and 2 plus the gate stay here, because every route through them is above the seat: renaming published keys is an ADR-0087 conversion each, and standing up a new spec-source gate is governance. Direction is the maintainer's.
Routing
domain:spec—packages/specis one contract lane whole, and a gate underpackages/spec/scripts/orbits the spec contract rather than general dev tooling (lane table's spec/devx split).priority:p3: zero measured pull — every reader is correct today, by this card's own measurement — and the exposure is the next key added, not any key that exists.needs-user-decision.<!-- os-decision-facets -->
timeoutMs)、单位只写在描述里(timeout/ttl)、单位跟着值走('14d'、{value, unit})。三种并存本身就是契约增生。长远终态应当只有一种,而且是读的人不用翻文档就知道的那种。加一道门禁把新键钉死在这条线上 = 缩小特例;把存量全部改名 = 同一个终态早到几个月,代价是每个已发布键一次破坏性转换。database-loader.ts:204按毫秒读内层ttl,正确)。没有客户在撞这件事。零拉动。按分歧推荐序,零拉动⇒荐④不扩散 —— 不为一个没人撞上的问题做批量改名。ttl: 3600就照抄进内层ttl,那里按毫秒算,一小时于是变成 3.6 秒。不报错、不告警,缓存只是一直是冷的,谁也不会去查。把单位钉进键名或值 = 抄错时名字本身就对不上,是写代码当场的响亮拒绝;留在描述里 = 静默错 1000 倍。这一棱最响,而且它要的是「下一个键不会错」,门禁正好只管下一个。推荐:A —— 只加门禁,从今天的计数棘轮(ratchet),存量全部计入基线不动。 新增的时长型
z.number()键必须单位在键名或在值里。①(收敛到一种写法)与④(不做批量破坏性改名)同向,②零拉动支持不动存量,③要的保护由门禁全额提供。回退:B —— 门禁 + 分批把存量按 ADR-0087 转换掉,从两个
ttl撞名那一对开刀(全仓唯一一处同名不同单位)。若维护者判定「同名不同单位」本身就是必须现在拆掉的雷,走这条。置信缺口(本分析看不见什么): 看不见这三种写法在
cloud仓和真实客户配置里的分布 —— 本轮量到的是 spec 侧的声明面,不是配置面,所以「零事故」这个读数只覆盖本仓的读取方。另外门禁该落在packages/spec/scripts/还是packages/lint,本席不判,那是拿到方向后由domain:spec席位定的落点,不影响方向选择。⚙️ Epic delegation — subtree owned by
session_01G4138K1EG7kQ81FNba5Kp4(os-sales) from 2026-09-05T07:53ZDelegated under the maintainer's direct-dispatch channel, authorised in-session: 「你继续负责跟进这个任务到合并」, 「15626你应该负责修复红啊」, 「我驱动整条栈」, 「用epic 卡派发」. Rationale and the measurements behind it: seat post #6017 comment
5550404209.⛔ Other PMs: skip this whole subtree when acquiring candidates. The
domain:specseat (session_01M59rPZZFzqhfMUPFqqZTkf) still holds post #6017 and the rest of the lane — ⛔ only this subtree moved.Sub-issues (the epic queue, re-read each round, never cached): #15676 (1/6, claimed, dispatching) · #15677 · #15678 · #15679 · #15680 · #15682 — the last five claimed and held at
pm:blocked, which is their true state: they are defined against what remains after 1/6 declares the exemption classes.Declared file territory (narrow on purpose — a wider claim would be less honest, not safer):
packages/spec/src/**— the gate's declared population, where the conversions landpackages/spec/scripts/check-duration-unit-keys.ts— the gate itself (1/6 teaches it the two exemptions; 6/6 widens its population)packages/spec/src/migrations/**— ADR-0087 registry entries per renamed keypackages/{objectql,metadata,runtime,rest,services,platform-objects}/**skills/**,docs/adr/**,.claude/**,content/docs/releases/, and every card outside this subtree.Sequencing — the
domain:specseat's, not mine, quoted from #15676: "branches stacked on #15626's; the set lands together whencheck:duration-unit-keysis green on the stacked head", and PR #15626 "stays a draft (governed —skills/**— human merge) and is ⛔ not landed with the gate red or unwired".pnpm --filter @objectstack/spec check:duration-unit-keyson the stacked head. ⛔ Nobody recounts by another rule. Baseline: 65 offenders / 215 duration-shaped keys / 762 files on PR #15626 head140e0b266, unchanged by the 63-commit merge.⇒ If the
domain:specseat wakes, it says so on #6017 and the subtree goes straight back.Generated by Claude Code