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.

Activity

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions