Skip to content

spec: duration-shaped number keys carry their unit in describe prose only — two ttl keys with different units in one block, bare timeout keys, unit-less tenant timeouts #14478

Description

@os-zhuang

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:

  1. 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.
  2. 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.
  3. 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:specpackages/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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions