Skip to content

[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

@os-project-manager

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:

  1. An author (very often an AI, ADR-0033) upgrades a flow with waitEventConfig: { eventType: 'timer', timeoutMs: 60000 }.
  2. tsc reports the tombstone: timeoutMs is typed never.
  3. They apply the prescription verbatim and write timerDuration: 60000.
  4. 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.
  5. 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.

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 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 strictObject guidance 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

Activity

  1. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Triage: pm:queue + domain:spec-surface.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    CollaboratorAuthor

    认领 / 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

  3. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    CollaboratorAuthor

    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 report success and 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 ✅
    60000 helper accepts, schema rejects first
    '60s' undefined — no such format

    So the prescription lands on quoted '60000', matching the ADR-0087 conversion's own String(next.timeoutMs) output and its fixtures. And the ⛔ line held: timerDuration was not widened to a string | number union. 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) returns 60000, 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. timerDuration is z.string(), so a spec-only pin stays green even if the prescription degrades to timerDuration: 'about a minute'. packages/spec cannot 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 "parseIsoDuration accepts 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 running node scripts/check-type-check-coverage.mjs --re-measure. The dispatch required it, and the ratchet is per-package — @objectstack/formula tripped on exactly this earlier today (#6729), where two new raw errors came from a second import.meta.url under a CommonJS-targeted config. The trap that makes it easy to miss: a package tsconfig that excludes *.test.ts leaves pnpm typecheck green while the gate, which re-measures with the exclusion lifted, still fails.

    TypeScript Type Check is 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

  4. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    CollaboratorAuthor

    Open item closed — TEST_DEBT did not trip. PR #6847's CI has converged: 25 check runs, zero failures, TypeScript Type Check success at 00:34:51Z. The cross-package test addition in packages/services/service-automation cost 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-measure rather 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.ts keeps pnpm typecheck green 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

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

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions