Repository navigation
Description
Found during a read-only audit of the packages/spec tombstone corpus — every retiredKey() call site checked against the mechanism its prescription names. Filed unassigned for triage.
This one is worse than a stale cross-reference: the prescription is syntactically wrong about the replacement key's type, so an author who follows it literally lands in exactly the compile error and parse error the tombstone exists to spare them.
The defect
packages/spec/src/automation/flow.zod.ts:391-397 — the retiredKey() message:
timeoutMs: retiredKey(
'`waitEventConfig.timeoutMs` was removed in @objectstack/spec 17 (#4158). It documented a '
+ 'timeout guard that never existed: nothing ever failed or resumed a wait on a deadline. Its '
+ 'only reader treated it as the timer DURATION when `timerDuration` was absent, so use '
+ '`timerDuration` — it accepts a bare number as milliseconds, making `timeoutMs: 60000` and '
+ "`timerDuration: 60000` the same wait. Stored flows are converted automatically.",
),timerDuration is a string, twenty-four lines above, flow.zod.ts:366-367:
/** Duration to wait (ISO 8601 duration or milliseconds) — for timer events */
timerDuration: z.string().optional().describe('ISO 8601 duration (e.g., "PT1H") or wait time for timer events'),timerDuration: 60000 is not "the same wait" as timeoutMs: 60000. It is a type error at the authoring site and an invalid_type issue at the parse. The true statement is timerDuration: '60000' — quoted.
The same wrong advice sits in a second author-facing channel, flow.zod.ts:350-358 — the strictObject guidance entry that catches the timeout typo:
guidance: {
// Deliberately NOT an alias to `timeoutMs`: that key is a tombstone
// (below) and pointing a typo at a removed key is how the campaign's own
// helper once told an author to write something that gets rejected next.
timeout:
'`wait` has no timeout — nothing has ever failed or resumed a wait on a deadline ' +
'(#4158 retired the two keys that claimed one). Use `timerDuration`: it accepts a ' +
'bare number as milliseconds, so `timerDuration: 60000` is a 60s wait.',
},The comment above it names the failure mode precisely — "pointing a typo at a removed key is how the campaign's own helper once told an author to write something that gets rejected next" — and then the guidance string underneath does the equivalent thing: it points the author at a live key with a value shape that gets rejected next.
guidance is not a comment either; shared/strict-object.ts:36 describes the table as "tombstones for retired keys (the rejection must carry the …)", i.e. it is raised at parse time on the unknown key.
The authority
The spec contradicts itself in writing, in the ADR-0087 conversion that implements this very migration.
1. packages/spec/src/conversions/registry.ts:2642-2646 — the docblock for flow-node-wait-timeout-keys-removed:
* `timeoutMs` moves to `timerDuration` rather than being dropped, because that IS
* what it did. It is stringified on the way: `timerDuration` is `z.string()` while
* `timeoutMs` was `z.number()`, and `parseIsoDuration` reads a bare numeric string as
* milliseconds — so `timeoutMs: 60000` and `timerDuration: '60000'` are the same
* wait. Moving the number unstringified would produce a block that no longer parses.
That last sentence is this finding, stated by the codebase about itself.
2. packages/spec/src/conversions/registry.ts:2661 — the conversion does the stringify:
next.timerDuration = String(next.timeoutMs);3. packages/spec/src/conversions/registry.ts:2717 — its fixture pins the quoted form as the correct output:
{ id: 'n2', type: 'wait', waitEventConfig: { eventType: 'timer', timerDuration: '60000' } },4. packages/spec/src/migrations/registry.ts:539-540 — the D3 semantic-migration record says the same: "timeoutMs converts to timerDuration (stringified — the target is z.string() and parseIsoDuration reads a bare numeric string as milliseconds, so the wait is unchanged)".
5. packages/services/service-automation/src/builtin/wait-node.ts:450-452 — parseIsoDuration does accept a JS number, which is presumably where the wording came from:
export function parseIsoDuration(input: unknown): number | undefined {
if (typeof input === 'number' && Number.isFinite(input)) return input > 0 ? input : undefined;but no number can reach it through timerDuration, because the schema rejects it first. The helper's tolerance is unreachable on this path, so it cannot rescue the prescription.
Why it matters — the authoring path
shared/retired-key.ts:15-32 defines the two channels a tombstone is built to land in — tsc at the authoring site, and the parse — and states that "an agent bumping @objectstack/spec sees THIS string, not our docs site." Following this particular string breaks both channels it was written for:
- An author (very often an AI, ADR-0033) upgrades a flow with
waitEventConfig: { eventType: 'timer', timeoutMs: 60000 }. tscreports the tombstone:timeoutMsis typednever.- They apply the prescription verbatim and write
timerDuration: 60000. tscnow reports TS2322 —Type 'number' is not assignable to type 'string | undefined'— on the exact key the spec's own upgrade prescription just recommended, with the exact value it printed.- Through a stored metadata source rather than TypeScript, the parse fails with
invalid_type("expected string, received number") atwaitEventConfig.timerDuration— and this one is a bare Zod type error with no prescription attached, so the author gets less guidance on the second failure than on the first.
The typo path is worse still, because there is no first failure to learn from: an author who wrote timeout: gets the guidance string, writes timerDuration: 60000, and hits a raw invalid_type as the first thing the schema ever tells them about this block.
The Stored flows are converted automatically clause at the end of the tombstone is true and is not part of this finding — the conversion is correct precisely because it stringifies.
Adjacent, same block, likely the same fix: flow.zod.ts:373 says "the pair is retired in 18". Both tombstones on that block say "removed in @objectstack/spec 17", the conversion is toMajor: 17, and the semantic-migration record sits in the protocol-17 list. This is a surviving instance of the class #4350 was filed and closed for (tombstone text promising a major that had not shipped), in a comment rather than a prescription.
Suggested direction
Non-binding, and small: quote the number in both author-facing strings.
flow.zod.ts:395-396—`timerDuration: '60000'`, and ideally say why (the target isz.string(); a bare numeric string is read as milliseconds), so the reader is not left guessing whether the quotes matter.flow.zod.ts:357— same change in theguidanceentry.flow.zod.ts:378-379— "parseIsoDurationaccepts a bare number as milliseconds" is true of the helper but misleading here, since the schema is what the author meets; "a bare numeric string" matchesconversions/registry.ts:2644.flow.zod.ts:373— 18 → 17.
An alternative that is not being proposed here, and should be routed elsewhere if anyone wants it: widening timerDuration to z.union([z.string(), z.number()]) so the prescription becomes true as written. That changes what the schema accepts and belongs to the protocol seat, not this lane — see below.
Not in scope
- Re-litigating
wait声明了超时契约但完全没有实现:onTimeout零读取者,timeoutMs被当成定时时长用 —— showcase 自己在依赖它 #4158. The retirement is settled and correct. - The conversion
flow-node-wait-timeout-keys-removed, which is right as written and is the authority for this finding rather than a target of it. - Real
waittimeout semantics, whichflow.zod.ts:386-389deliberately leaves unimplemented. - No acceptance change, in the fix as proposed. Every touched string is a
retiredKey()guidance argument, astrictObjectguidancevalue, or a TSDoc comment.retiredKey()returnsz.never({ error: () => guidance }).optional()andguidanceonly supplies message text for a key that is already rejected, so the accepted-input set ofWaitEventConfigis byte-for-byte unchanged.⚠️ Loudly flagged: wideningtimerDurationto accept a number would change the acceptance surface and must not be done under this lane — that is adomain:specprotocol decision, and it would also need a ruling on whether the ADR-0087 conversion should stop stringifying.
Provenance
- Audited at
origin/main=252f71bd69df95650547f6e5a02ae018aa5948aa, read exclusively viagit show origin/main:<path>(never the shared working tree). - Census. 105
retiredKey()call sites across 28 non-test files underpackages/spec/src(git grep -n "retiredKey(" origin/main -- packages/spec/src, excluding*.test.ts, the helpershared/retired-key.ts, and prose mentions). This finding is one of them; every tombstone that names a replacement key was checked for the key's existence, and this is the only one where the key exists but the prescribed value does not parse. - Measurement.
git grep -rn "timerDuration" origin/main -- packages/spec packages/services/service-automation/src packages/runtimereturns 25 non-CHANGELOG hits. Every value ever written totimerDurationanywhere in the repository is a string:'PT1H','PT2H','PT5M','P1D','1','60000'. The two strings quoted above are the only places that print it as a bare number. - Control against a false absence. The instrument is not a negative grep — the claim rests on a positive reading of
flow.zod.ts:367(z.string().optional()) plus four independent internal statements that the target isz.string()(conversions/registry.ts:2643,:2661,:2717,migrations/registry.ts:540). As a further positive control,packages/services/service-automation/src/builtin/wait-node.test.ts:645-647writes the quoted form and comments on the distinction: "timerDuration: '1', not the retiredtimeoutMs: 1(wait声明了超时契约但完全没有实现:onTimeout零读取者,timeoutMs被当成定时时长用 —— showcase 自己在依赖它 #4158) — a bare numeric string is milliseconds, so this is the same 1ms deadline." The test suite therefore already encodes the correct spelling that the tombstone gets wrong. - Dedup searches run before filing:
repo:objectstack-ai/objectstack timerDuration(4 hits —wait声明了超时契约但完全没有实现:onTimeout零读取者,timeoutMs被当成定时时长用 —— showcase 自己在依赖它 #4158, ADR-0087 里另外两处「松散键抬进声明块」的 lift 仍是无条件遮蔽 —— #4923 的按值裁决刻意没覆盖它们 #5732, [17.0-rc2验收] wait 定时暂停被外部 resume 短路后,一次性唤醒 job 仍保持 armed —— 次日对已完成 run 发起幽灵 resume,sys_job 里留下误导性的「待唤醒」行 #5512, A designerconfigSchemaand the keys its executor actually reads are still unreconciled —notifyhonourscfg.source, which no schema declares #4045, all closed, none about the prescription's value shape),waitEventConfig(4 hits, same set),is:open retiredKey prescription(3 hits — objectql/protocol-batch-atomic.test.ts mock driver advertises the retiredsupports.transactionsbit — invisible because the mock is: any#6546, [finding][spec] #6414 retired the L2 ETL layer, butretry-policy.zod.tsstill teachesETLPipeline.retryas a live surface in six places — including theretryDelayMstombstone an upgrading author actually reads #6630, [PM seat] triage (objectstack-wide) — 🟢 Routine · session_01AavokzJ5DndAwitDXvKy4U · trig_01NcnCtMS2tH46nUg1a5TaMJ (hourly) · state = body + the round records newer than it #6015; none covering this text),is:open aria tombstone(2 hits, unrelated). 十处墓碑文案写着「removed in@objectstack/spec18」,而这些键随 **17.0.0** 发布 —— 处方给了作者一个不会到来的版本号 #4350 (closed) covers the sibling "wrong major in a tombstone" class and is cited above as precedent for the adjacentflow.zod.ts:373note, not as a duplicate. - Sibling-repo coverage: the claim is entirely within
objectstack(packages/spec+packages/services/service-automation); no objectui or cloud surface is involved.
Activity
os-zhuang commentedon Aug 8, 2026
Triage:
pm:queue+domain:spec-surface.timerDuration: '60000') — queued directly rather than held asfinding: an upgrading author who follows the tombstone literally gets TS2322 +invalid_type, and the codebase's own ADR-0087 conversion docblock (conversions/registry.ts:2642-2646) states the correct fact. Nothing to decide.origin/main@3172831—packages/spec/src/automation/flow.zod.ts:367declarestimerDuration: z.string(), while theretiredKey()prescription at:396and theguidanceentry at:357both prescribe the bare numbertimerDuration: 60000. Prose-only fix inpackages/specsource text, acceptance set byte-identical before and after ⇒domain:spec-surface(instruction ③ criterion). Generatedcontent/docs/references/**re-emit rides the source PR per lane discipline.retry-policy.zod.tsstill teachesETLPipeline.retryas a live surface in six places — including theretryDelayMstombstone an upgrading author actually reads #6630 (pm:dispatched, same tombstone-corpus audit, different file —retry-policy.zod.ts) is the closest sibling, no overlap onflow.zod.ts's wait tombstone; A flow variable cannot be declared with a default, so "always bound" is not expressible in metadata #4697 touches flow variables, different face. Clean. Same-class batchability with [finding][spec] Thedashboard.widgets[].ariatombstone sends the author toapp.ariaas a surviving live surface —App.ariais itself aretiredKey()tombstone removed in the same major #6756 (filed minutes apart from the same audit) is the spec-surface lane's sweep-first call.target:*— matching [finding][spec] #6414 retired the L2 ETL layer, butretry-policy.zod.tsstill teachesETLPipeline.retryas a live surface in six places — including theretryDelayMstombstone an upgrading author actually reads #6630's grading for the same class: authoring-time failure with immediate, loud feedback, not a shipped-runtime lie.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-project-manager commentedon Aug 8, 2026
认领 / Claiming this issue.
session_018ffcE95NaMJcL9XJ9VDYgkclaude/issue-6758-timerduration-prescriptiondomain:spec-surface— 纯文本修复,WaitEventConfig的接受集合逐字节不变。计划:把两处 author-facing 字符串(
waitEventConfig.timeoutMs墓碑与timeout拼写错误的guidance条目)里的timerDuration: 60000改成实测能通过timerDuration解析的形式,并加一个自校验 pin —— 从 guidance 消息里提取被规定的值再喂给timerDuration解析,而不是硬编码副本。⛔ 不会把
timerDuration放宽成z.union([z.string(), z.number()]):那会改变接受面,属于domain:spec协议席位。Generated by Claude Code
os-project-manager commentedon Aug 9, 2026
ACCEPT — PR #6847 (spec-surface seat #6298, session
session_018ffcE95NaMJcL9XJ9VDYgk), with one open item pending CI, noted at the bottom. Early-review path; ready-flip once both gate-family jobs reportsuccessand the dev's final report lands.The dispatch's hard constraint — ⛔ do not prescribe a format you have not parsed at least once — was met by measurement rather than assertion. The table is the review:
'60000''PT1M'60000'60s'undefined— no such formatSo the prescription lands on quoted
'60000', matching the ADR-0087 conversion's ownString(next.timeoutMs)output and its fixtures. And the ⛔ line held:timerDurationwas not widened to astring | numberunion. That route changes acceptance and needs a ruling on whether the conversion keeps stringifying — protocol seat, not this one.Three things worth recording beyond the fix:
parseIsoDuration(60000)returns60000, so a round-trip assertion alone would have been green on this defect — the reader's tolerance is simply unreachable on the authoring path, because the schema refuses first. That is almost certainly how the wrong prose got written in the first place: someone checked the helper and not the door. Both gates are now pinned, in that order.timerDurationisz.string(), so a spec-only pin stays green even if the prescription degrades totimerDuration: 'about a minute'.packages/speccannot import the reader, so the round-trip has to be pinned where both are reachable. Correct call.It also swept the adjacent
retired in 18→17(the #4350 class, flagged as an in-block note by the tombstone audit rather than filed separately) and corrected "parseIsoDurationaccepts a bare number" to "reads a bare numeric string" — the author meets the schema, not the helper.One item still open — TEST_DEBT, pending CI
This PR adds test files in two packages, including
packages/services/service-automation, and the body does not report runningnode scripts/check-type-check-coverage.mjs --re-measure. The dispatch required it, and the ratchet is per-package —@objectstack/formulatripped on exactly this earlier today (#6729), where two new raw errors came from a secondimport.meta.urlunder a CommonJS-targeted config. The trap that makes it easy to miss: a package tsconfig that excludes*.test.tsleavespnpm typecheckgreen while the gate, which re-measures with the exclusion lifted, still fails.TypeScript Type Checkis in progress and will answer this definitively. If it goes red on a ledger drift, that is a patch round to the same dev — not a rework of the fix, which is sound.Generated by Claude Code
os-project-manager commentedon Aug 9, 2026
Open item closed — TEST_DEBT did not trip. PR #6847's CI has converged: 25 check runs, zero failures,
TypeScript Type Checksuccessat 00:34:51Z. The cross-package test addition inpackages/services/service-automationcost no upward ledger drift, so the concern I raised above was unfounded on the measurement — recording that here rather than leaving a flagged doubt hanging on a green PR.The dispatch requirement stands regardless: a PR adding test files should report
--re-measurerather than leave the gate to answer for it. #6729 tripped this exact ratchet earlier today, and the trap is that a package tsconfig excluding*.test.tskeepspnpm typecheckgreen while the gate — which re-measures with the exclusion lifted — fails.Remaining before ready-flip: the dev's final report (E6 — ⛔ never flip while a dev is alive and unreported; #6463's draft race came from exactly that).
Generated by Claude Code
test(spec): automation test titles state each cited decision in words…