Skip to content

[finding][spec] The waitEventConfig.timeoutMs tombstone prescribes timerDuration: 60000timerDuration 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-452parseIsoDuration 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 TS2322Type '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

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