Language
Schemas & Validation
The schema is the structure at the center of Luma. The same definition produces the JSON Schema sent to the gateway, validates the returned response locally, and builds the error text that is fed back to the model when validation fails. Since v0.4.0 it also validates data that never came from a model: every database row a query returns and every element a parallel over produces goes through the same rules.
Schema definition
schema Invoice {
invoice_number: string
total: float
}Fields are written one per line. A trailing comma is optional; putting two fields on the same line is a syntax error.
Schema-level metadata
schema Invoice
@desc("Sales invoice")
@prompt("Extract the invoice fields completely; set the ones you cannot find to null.") {
invoice_number: string
total: float
}| Annotation | Where | Effect |
|---|---|---|
@desc("…") | Schema header and fields | JSON Schema description. Given on the schema header it becomes the description of the root object and goes to the model. |
@prompt("…") | Schema header only | It is not sent to the model automatically. You read it as a string with schema.prompt(...) and add it to the prompt explicitly. |
If you write @prompt("…") on top of a field, the program stops with Unknown annotation: @prompt. Use @desc for a field-level description.
How to use @prompt
import schema from "std/schema"
schema InvoiceExtract
@desc("Structured data extracted from invoice Markdown")
@prompt("Produce JSON conforming to the InvoiceExtract schema from the Markdown below. Use the field descriptions as extraction instructions. Do not invent values you cannot find; use null in nullable fields and [] in array fields.") {
invoice_number: string | null
items: InvoiceItem[]
}
agent Extractor {
fn extract(markdown: string) -> InvoiceExtract {
instruction = schema.prompt(InvoiceExtract)
result = ask "{instruction}\n\nINVOICE MARKDOWN:\n{markdown}" -> InvoiceExtract
return result
}
}The split is deliberate: the extraction instruction is versioned together with the schema, but you decide where in the prompt it lands.
Field annotation reference
Field annotations are written after the field definition and can be chained. Long chains can be split across continuation lines that start with @.
| Annotation | Applies to | Repeat | Effect |
|---|---|---|---|
@desc("text") | every type | once | JSON Schema description. Skipped if the type is a direct schema reference. |
@title("text") | every type | once | JSON Schema title. |
@example(value) | every type | multiple | A JSON value; appended to the examples[] array. |
@deprecated("reason") | every type | once | deprecated: true + x-luma-deprecated-reason. |
@default(value) | every type | once | Fills the field in when it is absent; takes it out of required. |
@alias("name") | every type | multiple | An alternative input name; moved to the canonical name before validation. |
@feedback("message") | every type | once | Replaces every error message of this field. |
@valid(x => expression) | every type | multiple | A deterministic custom rule. |
@severity("error" | "warn") | the @valid before it | once | warn: failing the rule flags the value instead of stopping the run. Honoured on results, ignored on inputs — see @severity. |
@length(min[, max]) | string, string[] | once | Length in UTF-16 units — an astral character such as an emoji counts as 2. A single argument = exact length. |
@match("regex") | string, string[] | once | A regex pattern. |
@fmt("format") | string, string[] | once | Validation against a known format. |
@range(min, max) | numeric, numeric array | once | Value range (per element in an array). |
@minItems(n) | array | once | Minimum element count. |
@maxItems(n) | array | once | Maximum element count. |
@unique | array | once | Elements without repetition (takes no argument). |
@uniqueBy("field") | array | once | Uniqueness of the named field across a list of objects. |
@contains(value) | array | once | The named element must be present in the list. |
The type check resolves nullable unions: tax_id: string | null @length(10) is valid and the constraint is applied only to the string branch. In the same way scores: float[] | null @range(0, 100) is applied to the array branch.
Three commonly confused distinctions
1. Value range ≠ list length
| Written as | What is constrained |
|---|---|
quantity: float @range(1, 3) | The value of a single number is between 1 and 3. |
items: LineItem[] @minItems(1) @maxItems(3) | The length of the list is between 1 and 3. |
values: float[] @range(1, 3) | The value of every element in the list is between 1 and 3. The length is unbounded. |
2. @contains is for arrays
tags: string[] @contains("vip")
numbers: int[] @contains(2)
// To search for a substring in a scalar string:
name: string @valid(x => contains(x, "Ada"))// @contains cannot be used on a scalar
// string — the parser rejects it:
name: string @contains("Ada")
// @contains usage: @contains(value)3. @length(n) is an exact length
tax_id: string @length(10) // exactly 10 characters (minLength = maxLength = 10) name: string @length(2, 40) // between 2 and 40 characters
@fmt values
| Value | Check |
|---|---|
email | The a@b.com shape — a local part without spaces, @, and a dotted domain name. |
uri | A parseable absolute URL (the protocol is required). |
uuid | The standard UUID v1–v5 pattern. |
date | YYYY-MM-DD and a date that really exists (2024-02-31 is rejected). |
date-time | ISO 8601 (a Z or ±HH:MM offset is required) and it must be parseable. |
ipv4 | Four octets in the 0–255 range. |
ipv6 | Contains :, hexadecimal characters and colons only — a loose check. |
If you give an unrecognized value such as @fmt("phone"), no validation is performed and no error is raised either — the format is only written into JSON Schema as format. Use @match("regex") for custom shapes.
@valid
@valid takes a deterministic lambda: @valid(x => expression). The supported forms:
| Form | Example |
|---|---|
| Comparison | @valid(x => x > 0) · @valid(x => x == null) |
| Length | @valid(x => x.length > 3) |
| String methods | @valid(x => x.startsWith("TR")) · .endsWith(…) · .includes(…) |
| Built-in function | @valid(x => contains(x, "Ada")) |
| Std library | @valid(x => math.abs(x) < 10) · @valid(x => regex.matches(x, "^[A-Z]+$")) |
| Pure fn | @valid(x => validCode(x)) |
| Combination | @valid(x => x.length > 3 && x.startsWith("Ada")) |
The other annotations (@length, @range, @fmt…) are skipped when the value is null. @valid, by contrast, is always called. On a nullable field you have to put the null check into the expression itself:
// ✓ correct
code: string | null
@valid(x => x == null || regex.matches(x, "^[A-Z]{3}-[0-9]+$"))
// ✗ blows up when null arrives
code: string | null @valid(x => regex.matches(x, "^[A-Z]{3}-[0-9]+$"))&& and || combinations are split with string and parenthesis awareness, so a | character inside a regex pattern causes no trouble. An unsupported body is refused before the program starts, and that holds even when the schema is never validated — a rule that is never exercised is still checked, so it cannot lie in wait until the day the field first receives a value. Until v0.7.0 the parse-time check was only a shape approximation and this class arrived mid-run; the two checks are aligned now.
@alias and @default
schema Profile {
email: string @title("Email") @example("ada@example.com") @alias("e_mail") @alias("mail")
active: bool @default(true)
old_code?: string @deprecated("Use new_code instead")
}- If the incoming data contains
e_mail, the value is moved toemailand the alias key is removed. - If both the canonical name and an alias arrive together it is an error:
Profile.email cannot be given by its canonical name and an alias at the same time. - If more than one alias arrives together it is an error:
Profile.email was given more than one alias: e_mail, mail - An alias cannot be the name of a real field in the schema, and two fields cannot share the same alias.
- A value filled in by
@defaultthen goes through normal type validation.
Automatic validations
Even if you write no annotation at all, the checks below run for every schema:
| Check | Example error message |
|---|---|
| Required field | User.name missing. |
| Alias / default resolution | Profile.email cannot be given by its canonical name and an alias at the same time. |
| Type match | User.name must be a string. |
| Integer check | User.age must be an integer. |
| Finite-number check | User.score must be a number. |
| Boolean check | User.active must be a boolean. |
| Enum membership | User.role must be one of these values: admin, user. |
| Array and element type | Invoice.items must be an array. · Invoice.items[2].amount missing. |
| Tuple length | T.coordinate must contain exactly 2 items. |
| Map value type | Report.metrics.score must be a number. |
| Union resolution | Customer.tax_id does not match any union type: … | … |
| Nested schema | Every rule of the inner schema runs recursively. |
| Unknown field | User.extra is not defined in the schema. |
sources context check | Answer.sources contains an invalid context source: fabricated |
where invariants | Order does not satisfy its where rule: total == subtotal + tax |
when conditional rules | Operation conditional rule 'calc' requires field: kind == "calc" |
If the model returns a field that is not defined in the schema, it is not silently ignored: it becomes a validation error and triggers the retry loop. The generated JSON Schema also carries additionalProperties: false, so it is blocked on the provider side as well as locally.
The special field: sources
If you define a field named sources: string[], every value inside it is required to be a context ID declared in the program. This is the direct way to stop the model from inventing sources. The name is fixed by the runtime — sources is Turkish for "sources" and is hardcoded, so it cannot be renamed or translated; English programs use sources too.
context contract = file("docs/contract.md")
context spec = file("docs/spec.md")
schema Answer {
summary: string
sources: string[1..3] // only "contract" or "spec" is allowed
confidence: float [0..1]
}Files supplied with input document: file count as context IDs as well.
Errors are collected before they are reported
Every error found in one validation pass is accumulated and reported in a single go — so the model can fix all of them at once on the retry:
Error: A.f.v.name must be at least 2 characters. A.f.v.age must be an integer. A.f.v.email must be in email format. A.f.v.tags must contain unique items. A.f.v.score must be at most 100. A.f.v.role must be one of these values: admin, user. A.f.v.extra is not defined in the schema.
Customizing messages with @feedback
The default error messages are sent back to the model. Use @feedback when you want to state more clearly what the model is supposed to do.
schema Order {
subtotal: float
tax: float
total: float @feedback("The total must be the exact sum of the subtotal and the tax")
where total == subtotal + tax @feedback("total == subtotal + tax does not hold")
}| Where | Shape of the produced message |
|---|---|
| On a field | Path.field: <message> — every error of that field (type, length, range, @valid, absence) is replaced with this message. |
On a where | Path: <message> |
On a when / else | <message> — no path prefix is added. |
Error: A.f.v: total == subtotal + tax does not hold
This text goes straight into the retry prompt. Instead of "invalid value", build an actionable sentence such as "Write the amount including tax, as a number with a dot separator".
When the same schema validates a query row or a parallel over element, the @feedback text is not fed back anywhere — there is no model in that loop. It becomes the terminal error message and the run stops:
Error: BadRows.row[0]: total must equal subtotal + tax
No retry can fix it: the bridge returned what the database holds. On these paths write @feedback as a diagnostic aimed at whoever reads the failure — name the column and the expected relationship — rather than as an instruction aimed at a model. See Data & Integration.
where — deterministic invariants
where validates the relationship between fields. Use an annotation for a single field's own constraint and where for consistency across fields.
pure fn totalAmount(items: LineItem[]) -> float {
total = reduce item in items with 0 => acc + item.amount
return total
}
schema Invoice {
items: LineItem[1..100]
total_amount: float
where totalAmount(items) == total_amount @feedback("The line-item total must match the invoice total")
}| Rule | Detail |
|---|---|
| The result must be boolean | Otherwise you get where rule must return a boolean result. |
More than one where | Allowed — all of them run and each can produce its own error. |
| Usable expressions | Field references, comparisons, &&/||/!, built-in functions, pure fns and std modules. |
| IO is forbidden | An io.* call is rejected at parse time: where is deterministic and cannot use IO. |
| JSON Schema | Carried as the x-luma-where extension; the validation is performed locally. |
Floating-point comparison
import num from "std/numeric"
schema LineItem {
quantity: float
unit_price: float
total: float
where num.approximatelyEqual(quantity * unit_price, total, 0.01)
}schema LineItem {
quantity: float
unit_price: float
total: float
// 0.1 * 3 == 0.30000000000000004
where quantity * unit_price == total
}The schema body is parsed line by line, and a line is joined to the one above it only when it starts with a binary operator, a comma or a dot. So a long invariant can be wrapped for readability:
where quantity == null || unit_price == null
|| num.approximatelyEqual(quantity * unit_price, total, 0.01)
@feedback("does not match the a * b total")Lines beginning with @ continue the previous line too, which is how @feedback gets its own line. What does not continue is a line starting with a keyword — breaking before require still fails with Error: Invalid schema field: when kind == "company". If the expression really is getting long, move the logic into a pure fn and pass the fields in as arguments:
pure fn lineItemConsistent(quantity: float | null, unit_price: float | null, total: float | null) -> bool {
if quantity == null {
return true
}
if unit_price == null {
return true
}
if total == null {
return true
}
return num.approximatelyEqual(quantity * unit_price, total, 0.01)
}
schema LineItem {
quantity: float | null
unit_price: float | null
total: float | null
where lineItemConsistent(quantity, unit_price, total)
}Inside where there is no self standing for the object itself; fields are reached directly by name and passed to the function one by one.
where with nullable fields
schema InvoiceItem {
quantity: float | null
unit_price: float | null
total: float | null
where quantity == null || unit_price == null || total == null || num.approximatelyEqual(quantity * unit_price, total, 0.01)
@feedback("total does not match quantity * unit_price")
}For nullable fields, write the "skip when there is no data" conditions first and add the real invariant afterwards. || short-circuits from left to right. @feedback can be moved to the next line; where itself cannot.
@severity — the third state
Until v0.8.0 a validated value was in one of two states: it satisfied the schema, or the run stopped. @severity("warn") adds a third — valid, but flagged. The value is accepted and the run continues; what the rule found travels with it as a finding.
| State | How it arises | What happens |
|---|---|---|
| Valid | Every rule passes | The value is used. |
| Invalid | A rule with the default severity fails | The run stops with an error, as before. |
| Valid but flagged | Only @severity("warn") rules fail | The value is used and a finding is recorded against the operation that produced it. |
The default is error, so a schema that does not mention @severity behaves exactly as it did before. The set is closed: error and warn, nothing else. A tier vocabulary of your own — review, reject, a numeric score — belongs in the code that reads the findings, not in the rule.
Where it is written
On a field, @severity modifies the @valid rule it follows; on a where line it modifies that invariant. Those are the only two places, and both are checked at parse time.
schema Scored {
// the rule may fail without stopping the run
score: int @valid(x => x >= 0) @severity("warn")
// two rules, only the second one soft
ratio: float @valid(x => x <= 1) @valid(x => x > 0) @severity("warn")
}
schema Line {
qty: int
total: int
where total == qty * 5 @severity("warn")
}| Written as | Rejected with |
|---|---|
@severity("review") | @severity value must be "error" or "warn": review |
@severity with no @valid before it | @severity must follow the @valid rule it modifies. |
Any other annotation on a where line | where only supports the @feedback and @severity annotations: @desc |
Honoured on results, ignored on inputs
The same schema can describe a model's answer in one place and a tool's argument in another. Severity is not a property of the schema alone — it depends on which side of a boundary the value is being checked on.
| Path | Kind | What @severity("warn") does |
|---|---|---|
ask result | result | Honoured — the value is accepted and flagged. |
| Agent method return | result | Honoured. |
parallel branch · parallel over element | result | Honoured, per element. |
query row | result | Honoured, per row. |
| Tool output | result | Honoured. |
expect in a test block | result | Honoured. |
| Tool input | input | Ignored — the rule stays hard. |
| Agent method parameter | input | Ignored — the rule stays hard. |
pure fn parameter | input | Ignored — the rule stays hard. |
The reason is the difference between the two sentences those paths speak. A flagged result says "this data is doubtful, and here is why" — what to do about it is the program's decision. A flagged input would say "we are calling your side-effecting code with data we know breaks its contract", which is the silent-corruption class Luma spends its rules closing.
This is the rule with teeth, and it is worth seeing as one run. A method return is flagged and accepted; passing that same value into a parameter typed with the same schema then fails — the boundary re-imposes the contract:
Error: Sink.take.s.score does not satisfy its @valid rule: x => x >= 0
So warn widens what a producer may return; it does not widen what a consumer must accept. If you want a flagged value to travel further, read the findings and decide, or give the consumer a schema that admits the value honestly.
On the ask path, a soft rule still gets a retry
A soft violation is not a shrug. On the ask path it enters the retry loop exactly like a hard one: the failing rules are spoken back to the model with their paths, and the model gets its chance to correct. The first eight messages are quoted and the rest are counted ((+3 more)).
The difference is only at the end. When the attempts run out, a hard violation stops the run and a soft one does not: the last value is accepted, and the finding records the attempt number it was accepted on. Retry exists only where there is a model to feed — on the other result paths a soft violation goes straight to "accepted and flagged". This is the same asymmetry @feedback already has.
Reading the findings
std/findings is how a flag reaches the program. Both functions take the flagged value and are io, not pure:
import findings from "std/findings"
schema Scored {
score: int @valid(x => x >= 0) @severity("warn")
}
schema Audit {
score: int
flaws: int
first_rule: string
}
agent Maker {
fn make(value: int) -> Scored {
return { score: value }
}
}
agent Auditor {
uses maker: Maker
fn audit() -> Audit {
shaded = maker.make(-7)
flaws = findings.of(shaded)
return { score: shaded.score, flaws: length(flaws), first_rule: flaws[0].rule }
}
}Field of Finding | Type | What it holds |
|---|---|---|
rule | string | The rule that failed, as valid:<expression> or where:<expression>. |
path | string | Where in the value it failed, e.g. Maker.make.return.score. |
message | string | The same text the rule would have failed with. |
severity | string | "warn" today; the field exists so the shape survives a future value. |
attempt | int | The attempt the value was accepted on — 1 when there was no retry. |
findings.of and findings.count read run state, so they are io and cannot appear inside @valid, where or a pure fn:
Error: findings is io and cannot be used inside @valid or where rules: findings.count
That refusal is worth more than it costs: it stops a rule from reading its own violations, which is the shortest path to a rule whose result depends on the order the rules ran in.
Findings live per operation. Under parallel over each element collects its own and the results merge in input order, so a flag never lands on the wrong row.
Handing a flagged value to something that inspects it
A flagged value cannot cross a parameter typed with the schema whose soft rule it broke. That is deliberate, but it leaves a real question: how do you write the helper whose job is to look at flagged values? Typing the parameter any works and the flag survives the crossing — but it drops the type check for every other field too.
The typed way is to say what the boundary actually accepts: a candidate schema with the same shape that keeps the hard rules and leaves out the soft one being inspected.
schema Document {
amount: int @valid(x => x >= 0) @severity("warn")
name: string @length(2, 40)
}
// Same shape. Keeps @length; leaves out the rule under inspection.
schema DocumentCandidate {
amount: int
name: string @length(2, 40)
}
agent Reviewer {
fn review(doc: DocumentCandidate) -> Report {
flaws = findings.of(doc)
return { amount: doc.amount, flaws: length(flaws), rule: flaws[0].rule }
}
}The value crosses, findings.of still returns its flags, and doc.amount and doc.name stay typed — @length is still enforced at that boundary. What you gave up is exactly one rule, in writing, at the place where it matters.
Composing this across agents has a separate constraint worth knowing: a call through a uses dependency resolves in assignment position, not inside a return expression.
r = reviewer.review(doc) // this
return { amount: r.amount }
return reviewer.review(doc) // not thisToday the second form is refused with a message that names the wrong cause — tracked as #128.
A flag nobody read is still announced
Because reading is optional, an unread finding would be a value silently accepted — the very thing @severity is allowed to do only because it is visible. So the run does not go quiet. If findings were produced and never read, the run ends with a warning on standard error naming each producing operation and its count:
Warning: 1 finding accepted but never read: Maker.make.return (1). Read them with findings.of(...) from std/findings.
It is a warning, not an error: the exit code stays 0, because stopping the run would defeat the feature. Read the findings and the run is silent. This is the same bargain a bare call line makes — the language permits it and declines to be quiet about it.
The ceiling
A query returning two hundred thousand rows, multiplied by its rules, could accumulate findings without bound. So there is a ceiling of 1000 per operation. Past it, violations are counted instead of stored and a marker finding named findings.capped says exactly how many were cut:
Findings cap reached (1000); 412 more soft violations were not recorded.
Neither growing without bound nor shrinking without a trace — the count is always in the record.
@severity is part of the schema hash that --record and --replay match on, because it changes what the rule does. Adding or removing it invalidates a recording of that program — which is correct: the recording stops being valid exactly when the behaviour would have changed. Recordings of programs that use no @severity are unaffected.
when — conditional rules
when makes other fields required or forbidden according to a discriminator field. This is not a workflow if — it is for schema validation and JSON Schema generation.
This is the distinction that decides whether a when rule does anything at all. require asks whether the key is present; forbid asks whether it is absent. Neither one looks at the value, so null counts as present.
| Field | require does | forbid does |
|---|---|---|
x?: T (optional) | Rejects when the key is missing — the intended use. | Rejects when the key is present. |
x: T (required) | Nothing — the key is always there. | Always fails; the key can never be absent. |
x: T | null (required, nullable) | Nothing. null is a present key, so the rule is a no-op. | Always fails, for the same reason. |
The middle row of that table is the trap. A required nullable field can never be missing — the field-level missing check runs before the conditional does — so writing when kind == "calc" require [amount] against amount: float | null reads like "amount must be filled in for a calc" and enforces nothing.
To constrain the value, use where with an explicit null check:
where kind != "calc" || amount != null
@feedback("amount is required for a calc operation")Syntactically forbid follows require, and an empty list is legal: require [] forbid [x] is how you write a forbid-only rule.
schema Operation {
kind: enum["calc", "text"]
calc?: CalcInput
text?: string
when kind == "calc" require [calc] forbid [text]
@feedback("The calc field is required in a calc operation")
when kind == "text" require [text] forbid [calc]
@feedback("The text field is required in a text operation")
}else when / else chains
schema Envelope {
payload: Payload
when payload.kind == "calc" require [payload.calc] forbid [payload.text]
@feedback("The calc field is required in a calc payload")
else when payload.kind == "text" require [payload.text] forbid [payload.calc]
else require [payload.text]
@feedback("The text field is required for other payload kinds")
}whenstarts a new group;else whenandelsejoin the previous group.- Within a group the first matching branch is applied; if none match, the
elsebranch runs. - A nested field path can be written with a dot:
payload.calc. - An
elsecannot be written without awhenbefore it. - Every field name that appears in the rules must exist in the schema, otherwise you get a parse error.
Conditional defaults
schema Operation {
kind: enum["calc", "text", "other"]
currency?: string
description?: string
when kind == "calc" default [currency = "TRY"]
else when kind == "text" default [currency = "TEXT", description = "Text operation"]
else default [currency = "GENERIC"]
}A conditional default is applied only when the field is absent; an existing value is not overwritten. The filled-in value then goes through normal type validation. Defaults run before the requiredness checks.
How it maps to JSON Schema
When the condition has the form field == constant, an if/then/else structure is produced in JSON Schema:
"allOf": [
{ "if": { "properties": { "kind": { "const": "company" } } },
"then": { "allOf": [ { "required": ["tax_id"] } ] } }
]If the condition is a function call — for example when num.approximatelyEqual(value, 3, 0.1) require [rounded] — it cannot be translated into if/then in JSON Schema. The rule still runs exactly as written during local validation, but the model does not see the constraint at request time. Keep the rules you want the model to see in the field == constant form.
when, or a tagged union?
Everything above is about a conditional requirement inside one schema: a field that becomes required, forbidden or pre-filled because another field holds a particular value. That is what when is for, and it stays the right tool for it.
The same rules were also carrying a second, quite different job: saying "this object is exactly one of these shapes" — a kind enum, one branch field per shape, and a require/forbid pair per branch. That job now has a declaration of its own:
schema Invoice {
invoice_number: string
}
schema Receipt {
reference: string
}
schema Document = Invoice | Receipt tagged by kindThe difference is not cosmetic. In the envelope, exclusivity is an invariant you wrote: forget the when rule for one branch and an object carrying both branches at once validates cleanly — nothing points at the rule that is missing. A tagged union cannot be wrong that way, because exclusivity is the type: a value carries one member and its tag, and the match that consumes it has to name every member. Leave one out and the program never runs — it is rejected while parsing, with Match for tagged union 'Document' must handle member 'Receipt'.
Nothing is taken away here. The envelope still validates exactly as it did, and it stays the answer when the branches are not schemas in their own right or when other fields have to sit beside the branch; a union member also keeps its own where, when and annotations, which run at full strength whenever a Document arrives as an Invoice. For the declaration itself, match, and what the tag holds → Type System · Tagged union.
Schema composition
schema User {
id: string
email: string @fmt("email")
}
schema AuditFields {
created_at: string @fmt("date-time")
created_by: string
}
schema Admin = User + AuditFields| Behavior | Detail |
|---|---|
| Fields | The fields of the parts are merged in order. |
| Collision | If a field of the same name exists in two parts it is an error: Admin composition field conflict: id |
where rules | Carried over from every part. |
when groups | Carried over and renumbered so that the group numbers do not collide. |
@desc | Can be given with schema Admin @desc("…") = User + AuditFields. |
| Cycle | Rejected: Schema composition cycle found: A -> B -> A |
| Reflection | schema.components(Admin) → ["User", "AuditFields"]; [] for a schema that is not a composition. |
A wrapper for any payload
Composition merges two schemas that both already exist. The other half of the reuse problem is a schema whose shape repeats while its payload changes — Page, Result, Envelope. Written out by hand, that is one copy per payload type:
schema LineItem {
sku: string
}
schema Customer {
name: string
}
schema Page<T> {
items: T[]
total: int
next_cursor: string | null
}
schema Report {
lines: Page<LineItem>
customers: Page<Customer>
}schema LineItemPage {
items: LineItem[]
total: int
next_cursor: string | null
}
schema CustomerPage {
items: Customer[]
total: int
// next_cursor was added on the left only
}Today both halves validate the same values. The difference shows up on the day next_cursor is added: on the left it lands on every payload at once, on the right it lands wherever the person editing remembered to put it. Nothing reports the copy that was missed, because nothing is wrong — CustomerPage is a perfectly valid schema, just a different one from the wrapper it was supposed to be. That is what drift is, and one declaration is the only thing that rules it out.
Page<LineItem> is expanded while parsing into a concrete schema named Page_LineItem, so nothing on this page treats it as a special case: where invariants, when groups, annotations, @desc, validation order and std/schema reflection all apply to it exactly as written on the generic. schema.name(Page_LineItem) answers with the expanded name and schema.rules(Page_LineItem) lists the rules the declaration carried. A parametric declaration is a template for schemas, not a new kind of schema. For the declaration form, the positions it may be written in and what the parser refuses → Type System · Parametric schema.
Validation order
When an object is validated the steps run in exactly this order. The order matters: defaults, for example, are filled in before the requiredness check, while where is left until last because it assumes every field is already valid.
There are three entry points into this sequence and they all run the same steps, so every rule below — annotations, when, where, composition — applies equally to data that never touched a model. Only the error path prefix differs:
| Entry point | Error path | What a failure means |
|---|---|---|
An ask result | Agent.method.return.field | The model returned the wrong shape. The message is fed back and the call is retried. |
A query row | QueryName.row[N].field | The database returned the wrong shape. Terminal — no retry can change what the table holds. |
A parallel over element | variable[N].field | One element's body produced the wrong shape. Terminal for that element; on_error continue drops it. |
1 alias resolution alias → move to the canonical name
2 @default fill-in constant value for absent fields
3 conditional defaults when/else default [ … ]
│
4 for every field
├─ alias collision check
├─ requiredness check "X.y missing."
├─ type validation (nested schemas recursively)
├─ sources context check (if the field name is "sources")
├─ collection rules minItems · maxItems · unique · uniqueBy · contains
├─ value rules fmt · length · match · range (per element)
└─ @valid expressions run on null values too
│
5 unknown field check "X.k is not defined in the schema."
6 when require / forbid the first matching branch in the group
7 where invariants
↓
every error is combined and reported in a single go- String rules (
@length,@match,@fmt) run only if the value really is a string. - Numeric rules (
@range, the range shorthand) run only if the value is a number. - Collection rules run only if the value is an array.
@validalways runs — the null guard is yours to write.
Schema reflection
Schemas are compile-time definitions; they cannot be modified at runtime. They can, however, be read deterministically through std/schema — to generate a prompt, produce a catalog or export the JSON Schema:
import schema from "std/schema" name = schema.name(Invoice) // "Invoice" description = schema.description(Invoice) // the @desc text or null instruction = schema.prompt(Invoice) // the @prompt text or null fields = schema.fields(Invoice) // field descriptors field = schema.field(Invoice, "invoice_number") var = schema.hasField(Invoice, "seller.name") rules = schema.rules(Invoice) // where + conditional rules parts = schema.components(Admin) // ["User", "AuditFields"] catalog = schema.catalog([Invoice, Receipt]) js = schema.toJsonSchema(Invoice)
For the full signature list and return shapes → Standard Library · std/schema.
Picking a schema out of a string and writing ask -> <that schema> is not an existing capability; the target schema is fixed in the source, and reflection reads a schema, it does not build one.
What a method returns is still one named type — but that type may be a tagged union, and then each branch is free to ask with its own member schema. The branching stays static, the model is shown one narrow schema per branch, and the result comes back carrying its tag:
schema Invoice {
invoice_number: string
}
schema Receipt {
reference: string
}
schema Document = Invoice | Receipt tagged by kind
agent Extractor {
fn extract(kind: string, text: string) -> Document {
switch kind {
case "Invoice" { ask "Extract the invoice fields:\n{text}" -> Invoice }
default { ask "Extract the receipt fields:\n{text}" -> Receipt }
}
}
}