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—:patternis 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 evenoddstores a symbol value and checks it againstevenodd | nonzero.:shape (circle ...)stores a form value and checks the form head againstcircle | 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:
E4satisfiespitch.(n G4 0.5b)satisfiesevent._satisfiespitch.(rest 0.25b)satisfiesevent.
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:
| Diagnostic | What to reread |
|---|---|
wrong_underlying | The kind’s underlying shape: number, string, symbol, vector, form. |
vector_length_mismatch | The vector length requirement. |
unit_required | The unit requirement on a number-underlying kind. |
unit_not_allowed | The allowed unit suffix list. |
not_member | The closed symbol or string member list. |
not_head_member | The closed form head list. |
union_no_branch_matched | The union alternatives. |
string_too_short / string_too_long | The :min-len / :max-len codepoint bound. |
string_format_mismatch | The named format checker (email / uri / path / uuid / semver). |
string_pattern_unsupported | The :pattern constraint is informational in this build (no regex engine). |
This is the repair loop:
- Find the form and key in the diagnostic path.
- Read that key’s declared value type in the plugin docs.
- If the key names a value kind, read the value-kind line.
- 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
-
What does a member set usually mean for authoring?
-
What does
union_no_branch_matchedtell you to reread? -
When a union slot lists
formas one alternative, does that mean any parenthesized construct is accepted? -
What does a head set constrain?