Skip to content

NotifyConfigSchema.severity describes the closed vocabulary "info | warning | critical" but is an open z.string() — 'urgent' parses green and is blind-cast downstream #7086

Description

@os-project-manager

Finding from the axis-① .describe() sweep (describe claims vs measured acceptance face — the #6762 class). Recorded unassigned; suggest domain:spec-surface for routing. Sibling of the sourceObject/sourceId finding on the same schema (#7085 — different field, different claim type).

Anchor

packages/spec/src/automation/io-node-config.zod.ts, NotifyConfigSchema:

/** Severity forwarded to the messaging service. */
severity: z.string().optional().describe('info | warning | critical'),

Described claim

The describe is nothing but a closed three-value enumeration: info | warning | critical. No "e.g.", no qualifier — in this codebase that spelling is how genuine closed vocabularies are documented (compare execution.zod.ts regionKind's "loop-body | parallel-branch | try | catch", where the engine emits exactly those four).

Measured acceptance face

Probed on origin/main @ 2f3e79351 (tsx safeParse, sources via git archive; controls both sides — same run as #7085, whose unknown-key control rejected):

severity "info"      -> ACCEPTED
severity "warning"   -> ACCEPTED
severity "critical"  -> ACCEPTED
severity "urgent"    -> ACCEPTED
severity "INFO"      -> ACCEPTED
severity "p1"        -> ACCEPTED
severity ""          -> ACCEPTED

The field is a bare z.string() — the vocabulary exists only in the sentence.

What happens to an out-of-vocabulary value downstream

Measured on the same checkout — no layer closes the gap, and the types downstream pretend it is closed:

  • the notify executor forwards it raw: const severity = cfg.severity ? String(cfg.severity) : undefined (packages/services/service-automation/src/builtin/notify-node.ts);
  • the messaging dispatcher blind-casts it into the closed union: severity: (p.severity as Notification['severity']) ?? 'info' (packages/services/service-messaging/src/dispatcher.ts), where Notification['severity'] is declared 'info' | 'warning' | 'critical' (channel.ts, messaging-service.ts).

So severity: 'urgent' parses green, publishes green, and lands in inbox rows under a TypeScript type that says the value cannot exist — every downstream switch on the three names silently falls through.

Why it matters for an authoring reader (ADR-0033)

content/docs/references/automation/io-node-config.mdx renders the row as | **severity** | string | optional | info \| warning \| critical |. The docs read as an enum; the gate is an open string; the runtime is a cast. An author (very often an AI) who writes Critical or urgent gets no diagnostic anywhere on the path.

Suggested shape, if triage wants it fixed

Either close the gate — z.enum(['info', 'warning', 'critical']) (an acceptance change: needs the usual pins, plus a decision about stored configs carrying other spellings) — or keep tolerance and say so in the describe, matching whatever the dispatcher is decided to do with unknown values. Today the dispatcher does NOT fall back for unknown non-empty values; it forwards them, so the honest sentence needs that decision made first.

Refs

#6762 (class specimen: closed enumeration in the describe, wider gate in fact), #7085 (sibling finding on this schema), ADR-0033.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions