You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[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
Found during a read-only audit of the packages/spectombstone 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 strictObjectguidance 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:
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:
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 }.
tsc reports the tombstone: timeoutMs is typed never.
They apply the prescription verbatim and write timerDuration: 60000.
tsc now 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") at waitEventConfig.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 is z.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 the guidance entry.
flow.zod.ts:378-379 — "parseIsoDuration accepts 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" matches conversions/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.
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 wait timeout semantics, which flow.zod.ts:386-389 deliberately leaves unimplemented.
No acceptance change, in the fix as proposed. Every touched string is a retiredKey() guidance argument, a strictObjectguidance value, or a TSDoc comment. retiredKey() returns z.never({ error: () => guidance }).optional() and guidance only supplies message text for a key that is already rejected, so the accepted-input set of WaitEventConfig is byte-for-byte unchanged. ⚠️Loudly flagged: widening timerDuration to accept a number would change the acceptance surface and must not be done under this lane — that is a domain:spec protocol 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 via git show origin/main:<path> (never the shared working tree).
Census. 105 retiredKey() call sites across 28 non-test files under packages/spec/src (git grep -n "retiredKey(" origin/main -- packages/spec/src, excluding *.test.ts, the helper shared/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/runtime returns 25 non-CHANGELOG hits. Every value ever written to timerDuration anywhere 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 is z.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-647 writes the quoted form and comments on the distinction: "timerDuration: '1', not the retired timeoutMs: 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.
Sibling-repo coverage: the claim is entirely within objectstack (packages/spec + packages/services/service-automation); no objectui or cloud surface is involved.
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 #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.