Luma public docs
Türkçe

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

luma
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

luma
schema Invoice
  @desc("Sales invoice")
  @prompt("Extract the invoice fields completely; set the ones you cannot find to null.") {
  invoice_number: string
  total: float
}
AnnotationWhereEffect
@desc("…")Schema header and fieldsJSON Schema description. Given on the schema header it becomes the description of the root object and goes to the model.
@prompt("…")Schema header onlyIt is not sent to the model automatically. You read it as a string with schema.prompt(...) and add it to the prompt explicitly.
@prompt is not a field annotation

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

luma
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 @.

AnnotationApplies toRepeatEffect
@desc("text")every typeonceJSON Schema description. Skipped if the type is a direct schema reference.
@title("text")every typeonceJSON Schema title.
@example(value)every typemultipleA JSON value; appended to the examples[] array.
@deprecated("reason")every typeoncedeprecated: true + x-luma-deprecated-reason.
@default(value)every typeonceFills the field in when it is absent; takes it out of required.
@alias("name")every typemultipleAn alternative input name; moved to the canonical name before validation.
@feedback("message")every typeonceReplaces every error message of this field.
@valid(x => expression)every typemultipleA deterministic custom rule.
@severity("error" | "warn")the @valid before itoncewarn: failing the rule flags the value instead of stopping the run. Honoured on results, ignored on inputs — see @severity.
@length(min[, max])string, string[]onceLength in UTF-16 units — an astral character such as an emoji counts as 2. A single argument = exact length.
@match("regex")string, string[]onceA regex pattern.
@fmt("format")string, string[]onceValidation against a known format.
@range(min, max)numeric, numeric arrayonceValue range (per element in an array).
@minItems(n)arrayonceMinimum element count.
@maxItems(n)arrayonceMaximum element count.
@uniquearrayonceElements without repetition (takes no argument).
@uniqueBy("field")arrayonceUniqueness of the named field across a list of objects.
@contains(value)arrayonceThe named element must be present in the list.
Works on nullable fields too

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 asWhat 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

✓ Correct
tags: string[] @contains("vip")
numbers: int[] @contains(2)

// To search for a substring in a scalar string:
name: string @valid(x => contains(x, "Ada"))
✗ Wrong
// @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

ValueCheck
emailThe a@b.com shape — a local part without spaces, @, and a dotted domain name.
uriA parseable absolute URL (the protocol is required).
uuidThe standard UUID v1–v5 pattern.
dateYYYY-MM-DD and a date that really exists (2024-02-31 is rejected).
date-timeISO 8601 (a Z or ±HH:MM offset is required) and it must be parseable.
ipv4Four octets in the 0–255 range.
ipv6Contains :, hexadecimal characters and colons only — a loose check.
An unknown format passes silently

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:

FormExample
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"))
@valid runs on null values too

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

luma
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 to email and 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 @default then goes through normal type validation.

Automatic validations

Even if you write no annotation at all, the checks below run for every schema:

CheckExample error message
Required fieldUser.name missing.
Alias / default resolutionProfile.email cannot be given by its canonical name and an alias at the same time.
Type matchUser.name must be a string.
Integer checkUser.age must be an integer.
Finite-number checkUser.score must be a number.
Boolean checkUser.active must be a boolean.
Enum membershipUser.role must be one of these values: admin, user.
Array and element typeInvoice.items must be an array. · Invoice.items[2].amount missing.
Tuple lengthT.coordinate must contain exactly 2 items.
Map value typeReport.metrics.score must be a number.
Union resolutionCustomer.tax_id does not match any union type: … | …
Nested schemaEvery rule of the inner schema runs recursively.
Unknown fieldUser.extra is not defined in the schema.
sources context checkAnswer.sources contains an invalid context source: fabricated
where invariantsOrder does not satisfy its where rule: total == subtotal + tax
when conditional rulesOperation conditional rule 'calc' requires field: kind == "calc"
Unknown fields are errors too

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.

luma
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:

text
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.

luma
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")
}
WhereShape of the produced message
On a fieldPath.field: <message> — every error of that field (type, length, range, @valid, absence) is replaced with this message.
On a wherePath: <message>
On a when / else<message> — no path prefix is added.
Error: A.f.v: total == subtotal + tax does not hold
Write the message like an instruction to the model

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".

On a data path there is nobody to instruct

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.

luma
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")
}
RuleDetail
The result must be booleanOtherwise you get where rule must return a boolean result.
More than one whereAllowed — all of them run and each can produce its own error.
Usable expressionsField references, comparisons, &&/||/!, built-in functions, pure fns and std modules.
IO is forbiddenAn io.* call is rejected at parse time: where is deterministic and cannot use IO.
JSON SchemaCarried as the x-luma-where extension; the validation is performed locally.

Floating-point comparison

✓ Tolerant
import num from "std/numeric"

schema LineItem {
  quantity: float
  unit_price: float
  total: float

  where num.approximatelyEqual(quantity * unit_price, total, 0.01)
}
✗ Fragile
schema LineItem {
  quantity: float
  unit_price: float
  total: float

  // 0.1 * 3 == 0.30000000000000004
  where quantity * unit_price == total
}
A where may be split, but only at an operator

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

luma
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.

StateHow it arisesWhat happens
ValidEvery rule passesThe value is used.
InvalidA rule with the default severity failsThe run stops with an error, as before.
Valid but flaggedOnly @severity("warn") rules failThe 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 asRejected 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 linewhere 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.

PathKindWhat @severity("warn") does
ask resultresultHonoured — the value is accepted and flagged.
Agent method returnresultHonoured.
parallel branch · parallel over elementresultHonoured, per element.
query rowresultHonoured, per row.
Tool outputresultHonoured.
expect in a test blockresultHonoured.
Tool inputinputIgnored — the rule stays hard.
Agent method parameterinputIgnored — the rule stays hard.
pure fn parameterinputIgnored — 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.

A flagged value does not cross a typed boundary

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:

luma
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 FindingTypeWhat it holds
rulestringThe rule that failed, as valid:<expression> or where:<expression>.
pathstringWhere in the value it failed, e.g. Maker.make.return.score.
messagestringThe same text the rule would have failed with.
severitystring"warn" today; the field exists so the shape survives a future value.
attemptintThe attempt the value was accepted on — 1 when there was no retry.
io, and that is the point

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.

luma
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.

A dependency call is a step, not an expression

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 this

Today 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.

A soft rule invalidates a recording

@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.

require and forbid are about the KEY, not the value

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.

Fieldrequire doesforbid 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.

luma
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

luma
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")
}
  • when starts a new group; else when and else join the previous group.
  • Within a group the first matching branch is applied; if none match, the else branch runs.
  • A nested field path can be written with a dot: payload.calc.
  • An else cannot be written without a when before it.
  • Every field name that appears in the rules must exist in the schema, otherwise you get a parse error.

Conditional defaults

luma
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:

json
"allOf": [
  { "if":   { "properties": { "kind": { "const": "company" } } },
    "then": { "allOf": [ { "required": ["tax_id"] } ] } }
]
Function conditions only run locally

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 kind

The 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

luma
schema User {
  id: string
  email: string @fmt("email")
}

schema AuditFields {
  created_at: string @fmt("date-time")
  created_by: string
}

schema Admin = User + AuditFields
BehaviorDetail
FieldsThe fields of the parts are merged in order.
CollisionIf a field of the same name exists in two parts it is an error: Admin composition field conflict: id
where rulesCarried over from every part.
when groupsCarried over and renumbered so that the group numbers do not collide.
@descCan be given with schema Admin @desc("…") = User + AuditFields.
CycleRejected: Schema composition cycle found: A -> B -> A
Reflectionschema.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:

✓ One declaration, any payload
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>
}
✗ One copy per payload
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.

The expansion is a normal schema

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 pointError pathWhat a failure means
An ask resultAgent.method.return.fieldThe model returned the wrong shape. The message is fed back and the call is retried.
A query rowQueryName.row[N].fieldThe database returned the wrong shape. Terminal — no retry can change what the table holds.
A parallel over elementvariable[N].fieldOne 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
Annotation behavior on null values
  • 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.
  • @valid always 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:

luma
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.

There is no dynamic schema selection

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 }
    }
  }
}