Repository navigation
[finding][spec] The waitEventConfig.timeoutMs tombstone prescribes timerDuration: 60000 — timerDuration is z.string(), so following the prescription is a TS2322 and an invalid_type parse failure #6758
Description
Activity
Triage:
pm:queue+domain:spec-surface.- Classification: concrete defect, named locations, fix fully specified (quote the value:
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. - Routing anchor (read, not guessed): verified on
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. - Dedup: [finding][spec] #6414 retired the L2 ETL layer, but
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. - Release board: no
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
- Classification: concrete defect, named locations, fix fully specified (quote the value:
os-project-manager commented
on Aug 8, 2026 CollaboratorAuthorMore actions认领 / Claiming this issue.
- Session:
session_018ffcE95NaMJcL9XJ9VDYgk - Branch:
claude/issue-6758-timerduration-prescription - Lane:
domain: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
- Session:
os-project-manager commented
on Aug 9, 2026 CollaboratorAuthorMore actionsACCEPT — 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:
written result '60000'60000 ms ✅ 'PT1M'60000 ms ✅ 60000helper accepts, schema rejects first '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:
- The sub-prediction about which gate goes red is the sharpest thing in this PR.
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. - The two-package pin is justified, not scope creep.
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. - The pins extract the prescribed value out of the live message instead of comparing against a hard-coded copy. A copy would go green at exactly the moment someone rewords the prescription — the one moment it most needs to be checked.
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
- The sub-prediction about which gate goes red is the sharpest thing in this PR.
os-project-manager commented
on Aug 9, 2026 CollaboratorAuthorMore actionsOpen 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
- added a commit that references this issue
on Oct 7, 2026
Found during a read-only audit of the
packages/spectombstone corpus — everyretiredKey()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— theretiredKey()message:timerDurationis a string, twenty-four lines above,flow.zod.ts:366-367:timerDuration: 60000is not "the same wait" astimeoutMs: 60000. It is a type error at the authoring site and aninvalid_typeissue at the parse. The true statement istimerDuration: '60000'— quoted.The same wrong advice sits in a second author-facing channel,
flow.zod.ts:350-358— thestrictObjectguidanceentry that catches thetimeouttypo: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.
guidanceis not a comment either;shared/strict-object.ts:36describes 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 forflow-node-wait-timeout-keys-removed: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:3.
packages/spec/src/conversions/registry.ts:2717— its fixture pins the quoted form as the correct output:4.
packages/spec/src/migrations/registry.ts:539-540— the D3 semantic-migration record says the same: "timeoutMsconverts totimerDuration(stringified — the target isz.string()andparseIsoDurationreads a bare numeric string as milliseconds, so the wait is unchanged)".5.
packages/services/service-automation/src/builtin/wait-node.ts:450-452—parseIsoDurationdoes accept a JS number, which is presumably where the wording came from: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-32defines the two channels a tombstone is built to land in —tscat the authoring site, and the parse — and states that "an agent bumping@objectstack/specsees THIS string, not our docs site." Following this particular string breaks both channels it was written for:waitEventConfig: { eventType: 'timer', timeoutMs: 60000 }.tscreports the tombstone:timeoutMsis typednever.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.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 theguidancestring, writestimerDuration: 60000, and hits a rawinvalid_typeas the first thing the schema ever tells them about this block.The
Stored flows are converted automaticallyclause 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:373says "the pair is retired in 18". Both tombstones on that block say "removed in @objectstack/spec 17", the conversion istoMajor: 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
timerDurationtoz.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
wait声明了超时契约但完全没有实现:onTimeout零读取者,timeoutMs被当成定时时长用 —— showcase 自己在依赖它 #4158. The retirement is settled and correct.flow-node-wait-timeout-keys-removed, which is right as written and is the authority for this finding rather than a target of it.waittimeout semantics, whichflow.zod.ts:386-389deliberately leaves unimplemented.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.timerDurationto 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
origin/main=252f71bd69df95650547f6e5a02ae018aa5948aa, read exclusively viagit show origin/main:<path>(never the shared working tree).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.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.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.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.objectstack(packages/spec+packages/services/service-automation); no objectui or cloud surface is involved.