Schemas and Diagnostics Reading Schemas Lesson 12

Value kinds: strings, members, heads, unions

Mental Model

Part 1 introduced the named-kind pattern and worked through underlying shapes, vector shapes, unit shapes, and numeric bounds. The four refinements in this chapter are the remaining axes a plugin can use to narrow what a slot accepts: codepoint-length and format constraints on strings, closed lists of symbol or string values, closed lists of allowed form heads, and unions that combine several alternatives behind one slot. The reading habit stays the same — underlying shape first, then refinement, then surface value — but each axis has its own diagnostic code, so the cheat sheet at the end of the chapter is the fastest path back to the right line in the plugin docs.

String Bounds

A string bound refinement applies to a string-underlying kind and constrains the value’s length, pattern, or format. Each field is optional; the validator applies them cheapest-first (length, then format, then pattern).

Example contracts:

slug:           string, length 1–64, format path
email-address:  string, format email
semver-string:  string, format semver

Accepted values for slug:

(post :id "intro")
(post :id "release-notes-2026-05")

Rejected because below :min-len:

(post :id "")

Likely diagnostic: string_too_short. Other diagnostics in this family:

  • string_too_long — codepoint count above :max-len.
  • string_format_mismatch — value fails the declared :format (one of email / uri / path / uuid / semver).
  • string_pattern_unsupported:pattern is declared but this build has no regex engine; warning, validation still succeeds.
  • string_pattern_mismatch — reserved for the engine milestone; v1 builds never emit it.
  • string_bounds_invalid — loader-emitted: empty range, negative bound, empty pattern, wrong underlying, or a member literal that itself fails the declared length / format.

Length is measured in UTF-8 codepoints, so "héllo" (5 cp / 6 bytes) passes :max-len 5. The parser guarantees well-formed UTF-8, so the count is total. No normalisation is applied — precomposed "é" (1 cp) and decomposed "é" (2 cp) count differently.

:pattern is accept-but-warn in v1: declaring it does not yet enforce a regex (the engine lands later), but the loader stores the source and the validator fires string_pattern_unsupported at every matched value site so authors know the constraint is informational. The wire shape is stable — once the engine lands, the same manifest starts enforcing the regex without a spec change.

Member Sets

A member set is a closed list of accepted values for a symbol-underlying or string-underlying kind.

For the shapes plugin:

fill-rule: symbol, members evenodd | nonzero

Accepted:

(circle :center [0 0] :radius 1 :fill evenodd)
(circle :center [0 0] :radius 1 :fill nonzero)

Rejected:

(circle :center [0 0] :radius 1 :fill diagonal)

Likely diagnostic: not_member. Repair with one of the documented symbols:

(circle :center [0 0] :radius 1 :fill evenodd)

Do not repair symbol member sets with keywords:

(circle :center [0 0] :radius 1 :fill :evenodd)

That runs into the keyword-pairing rule from chapter 5. :fill no longer receives a value; both :fill and :evenodd are read as positional flags. Closed enum-like values are usually symbols:

:fill evenodd

If a plugin documents a string-underlying member set, then use quoted strings instead:

blend-mode: string, members "normal" | "multiply"
(layer :blend "multiply")

The contract decides whether the closed values are symbols or strings.

Head Sets

A head set applies to a form-underlying kind. It says “the slot value must be a nested form, and the nested form’s head must be one of these.”

For the shapes plugin:

shape-form: form, heads circle | rect

Accepted:

(badge :label "dot"
  :shape (circle :center [0 0] :radius 1))

(badge :label "box"
  :shape (rect :origin [0 0] :size [10 10]))

Rejected:

(badge :label "bad" :shape (group :name "not-a-shape"))

group is a known form, but it is not a member of the allowed head set. Likely diagnostic: not_head_member. Repair with one of the allowed heads:

(badge :label "ok" :shape (rect :origin [0 0] :size [10 10]))

A head set is different from a symbol member set:

  • :fill evenodd stores a symbol value and checks it against evenodd | nonzero.
  • :shape (circle ...) stores a form value and checks the form head against circle | rect.

Unions

A union says “this value may satisfy any one of these alternatives.” Each alternative is another named kind or a primitive shortcut like number, symbol, or form.

The validator tries alternatives in the order the plugin declared them. As an author, the main thing to notice is the failure case: if no alternative accepts the value, the diagnostic lists the alternatives as a menu of legal shapes.

Example music contract:

pitch: symbol, members E4 | G4 | A4 | B4 | _
event: form, heads n | rest
note-or-event: union pitch | event

(phrase ...)
  :notes vector<note-or-event> optional

Now every element of :notes is checked against the union:

(phrase :notes [E4 (n G4 0.5b) _ (rest 0.25b)])

Read the elements:

  • E4 satisfies pitch.
  • (n G4 0.5b) satisfies event.
  • _ satisfies pitch.
  • (rest 0.25b) satisfies event.

This fails:

(phrase :notes [E4 X4 (n G4 0.5b)])

X4 is not in the pitch member set, and it is not a form, so no alternative accepts it. Likely diagnostic: union_no_branch_matched. Repair with a value that fits one branch:

(phrase :notes [E4 G4 (n G4 0.5b)])

The diagnostic lists the alternatives the plugin declared. Use that list as a menu of legal shapes.

Form Alternative Pitfall

A union alternative named form is not a wildcard for unknown parenthesized syntax.

Assume this contract:

vec4: vector, length 4, element number
value: union number | vec4 | form

Assume (set ...) itself is a known form. This still fails if foo is not declared by any loaded plugin:

(set :value (foo 1 2))

Likely diagnostic: unknown_form, not union_no_branch_matched. The slot accepts form-shaped values, but form heads still resolve against the schema. Repair by using a form whose head the schema knows:

(set :value (+ 1 2))

If a plugin truly wants to accept absolutely any value, the slot is typed any. A union containing form means “any declared form,” not “any parentheses.”

Diagnostic Cheat Sheet

When a value kind fails, the diagnostic usually tells you which layer of the contract you violated:

DiagnosticWhat to reread
wrong_underlyingThe kind’s underlying shape: number, string, symbol, vector, form.
vector_length_mismatchThe vector length requirement.
unit_requiredThe unit requirement on a number-underlying kind.
unit_not_allowedThe allowed unit suffix list.
not_memberThe closed symbol or string member list.
not_head_memberThe closed form head list.
union_no_branch_matchedThe union alternatives.
string_too_short / string_too_longThe :min-len / :max-len codepoint bound.
string_format_mismatchThe named format checker (email / uri / path / uuid / semver).
string_pattern_unsupportedThe :pattern constraint is informational in this build (no regex engine).

This is the repair loop:

  1. Find the form and key in the diagnostic path.
  2. Read that key’s declared value type in the plugin docs.
  3. If the key names a value kind, read the value-kind line.
  4. Rewrite the value to match the underlying shape first, then the refinement.

Exercises

For each exercise, read the contract first, then repair the source.

Member Set

Contract:

fill-rule: symbol, members evenodd | nonzero
(circle ...)
  :fill fill-rule optional
(circle :center [0 0] :radius 1 :fill diagonal)

Repair:

(circle :center [0 0] :radius 1 :fill evenodd)

Do not repair it with a keyword:

(circle :center [0 0] :radius 1 :fill :evenodd)

That triggers the keyword-pairing problem from chapter 5.

Head Set

Contract:

shape-form: form, heads circle | rect
(badge ...)
  :shape shape-form optional
(badge :label "bad" :shape (group :name "not-a-shape"))

Repair with an allowed head:

(badge :label "ok" :shape (rect :origin [0 0] :size [10 10]))

Union

Contract:

pitch: symbol, members E4 | G4 | A4 | B4 | _
event: form, heads n | rest
note-or-event: union pitch | event
(phrase ...)
  :notes vector<note-or-event> optional
(phrase :notes [E4 X4 (n G4 0.5b)])

X4 is not in the pitch member set, and it is not a form, so neither alternative accepts it. Likely diagnostic: union_no_branch_matched naming pitch and event. Repair with a value that fits one of the alternatives:

(phrase :notes [E4 G4 (n G4 0.5b)])

Union With Form Alternative

Contract:

vec4: vector, length 4, element number
value: union number | vec4 | form
(set ...)
  :value value required

Assume (set ...) itself is a known form.

(set :value (foo 1 2))

If foo isn’t a head in any loaded plugin, this fails with unknown_form. The form alternative does not turn the slot into “any parenthesized construct accepted.” Repair by using a form whose head the schema knows about:

(set :value (+ 1 2))

If you genuinely need to put a domain-specific construct here, check the plugin’s loaded form vocabulary first - or look for whether the plugin has a separate slot documented as any.

Mastery Check

  1. What does a member set usually mean for authoring?

  2. What does union_no_branch_matched tell you to reread?

  3. When a union slot lists form as one alternative, does that mean any parenthesized construct is accepted?

  4. What does a head set constrain?