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.
Finding from the axis-①
.describe()sweep (describe claims vs measured acceptance face — the #6762 class). Recorded unassigned; suggestdomain:spec-surfacefor routing. Sibling of thesourceObject/sourceIdfinding on the same schema (#7085 — different field, different claim type).Anchor
packages/spec/src/automation/io-node-config.zod.ts,NotifyConfigSchema: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 (compareexecution.zod.tsregionKind's "loop-body | parallel-branch | try | catch", where the engine emits exactly those four).Measured acceptance face
Probed on
origin/main@2f3e79351(tsxsafeParse, sources viagit archive; controls both sides — same run as #7085, whose unknown-key control rejected):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:
const severity = cfg.severity ? String(cfg.severity) : undefined(packages/services/service-automation/src/builtin/notify-node.ts);severity: (p.severity as Notification['severity']) ?? 'info'(packages/services/service-messaging/src/dispatcher.ts), whereNotification['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 downstreamswitchon the three names silently falls through.Why it matters for an authoring reader (ADR-0033)
content/docs/references/automation/io-node-config.mdxrenders 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 writesCriticalorurgentgets 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.