01 - Schema
A Rayfold schema is written in the .rayfold language or produced by a code-first builder. Both produce the same canonical IR (JSON, section 9). Everything else in Rayfold (execution, caching, auth, evolution checks, codegen, the MCP bridge) consumes the IR, never the source text.
1. Lexical structure
- Encoding UTF-8. Whitespace and commas are insignificant separators.
- Comments:
// lineand/* block */. - Documentation: a
"""triple-quoted"""string immediately before a definition, field, argument or enum value is its description. Descriptions are part of the IR (they are what agents and playgrounds see). As in GraphQL, the text is dedented (common indentation removed, leading and trailing blank lines dropped, the whole trimmed;\r\ncounts as a line break).\"""is the one escape: it stands for""", so a description can hold its own fence. - Names:
[A-Za-z_][A-Za-z0-9_]*. Type names SHOULD bePascalCase, fields and operationscamelCase. Names starting with__or$are reserved. - Literals: integers, floats,
"strings"(JSON escapes),true/false/null, durations (60s,5m,2h,7d,250ms), lists[...], objects{ key: value }. An object key that is not a name is quoted:{ "content-type": "text/plain" }.
2. Definitions
document := definition*
definition := entity | object | input | enum | union | scalar | error | event
| view | query | command | stream2.1 entity
An entity has identity. It MUST declare a field id: ID. Its global identity on the wire and in caches is the string TypeName:id.
entity Book @cache(maxAge: 60s, scope: public) {
id: ID
title: String
price: Money @allow(read: viewer.role == "admin" || viewer.id == ownerId)
author: Author @load(batch)
reviews(first: Int = 10, after: String?): Page<Review>
ownerId: ID
}An entity MAY declare implements Iface1, Iface2 (interfaces are object definitions marked @interface; the entity MUST include all interface fields).
2.2 object
A value type without identity. Same field syntax; no id requirement. Objects are never cached on their own and never patched; they live inside the entity or result that contains them.
2.3 input
An argument-only type. Fields MAY have defaults. Inputs MUST NOT reference entities or objects, only scalars, enums and other inputs.
2.4 enum, union, scalar
enum Format { HARDCOVER PAPERBACK EBOOK }
union SearchHit = Book | Author
scalar Money @format("decimal")Built-in scalars: ID, String, Int (32-bit), Long (64-bit; JSON encodes as string when it exceeds 2^53), Float (a NaN or an infinity, which JSON cannot hold, is written null), Boolean, Decimal (JSON string), Instant (RFC 3339 UTC), Date (YYYY-MM-DD), Duration (250ms, 60s, 5m, 2h, 7d, or a whole number of milliseconds), Bytes (base64url), JSON (any value), and the generic Page<T>.
2.5 error
A named, reusable domain error. Fields describe the structured payload.
error OutOfStock { bookId: ID, available: Int }Errors MAY also be declared inline in a throws clause (2.8) and are then hoisted into the IR as named errors.
2.6 event
A published fact. Events have fields and are immutable. The runtime stamps seq, a per-server arrival counter, on delivery; it is not a field of the type and does not appear in the IR.
event OrderPlaced { orderId: ID }2.7 view
A named shape (see 02) attached to a type. The view named default is what a caller gets when it sends no shape. If a type declares no default view, its default view is every scalar and enum field that takes no arguments (which includes the id); nested entities and objects are left out entirely rather than reduced to their id. The built-in Page is the one exception: its derived default view includes items, projected through the element type's own default view, since a window with no rows in it would answer nothing.
view Book.default = { id title author { name } }
view Book.card = { ...Book.default price }2.8 Operations
query books(filter: BookFilter?, page: PageArgs = { first: 20 }): Page<Book> @cost(base: 5, perItem: 1)
command placeOrder(input: OrderInput): Order
throws OutOfStock | PaymentDeclined { reason: String }
emits OrderPlaced
stream inventory(bookIds: [ID]): InventoryUpdate| Kind | Semantics |
|---|---|
query | Safe and idempotent. Cacheable per 07. Shapeable. May be requested live when the live extension is present. |
command | Changes state. A client MUST send an idempotency key. Returns the declared result plus patches (04 §2). May declare throws (typed errors, 05) and emits (events). |
stream | Produces zero or more items of the return type until fin. Bidirectional when annotated @input(Type): the client sends items of Type as item frames. |
Events are consumed by declaring a stream that returns the event type. The name subscribe is reserved for a built-in subscription with replay, which is not specified or implemented in 0.1.
3. Fields, arguments and types
field := doc? name args? ":" type default? annotation*
args := "(" (doc? name ":" type default?)* ")"
type := named | named "?" | "[" type "]" | "[" type "]" "?" | named "<" type ("," type)* ">" ("?")?Nullability is opt-in. String is non-null; String? may be null. A list [String] is a non-null list of non-null strings; [String?]? allows both. This is the reverse of GraphQL, and matches real APIs, where most fields are required.
Defaults are permitted on arguments and input fields. A default implies the argument is optional at the call site but non-null in the resolver.
4. Annotations
Annotations attach machine-readable policy to a definition or field. Core annotations:
| Annotation | On | Meaning |
|---|---|---|
@cache(maxAge: Duration, scope: public | private, swr: Duration?) | entity, query | Freshness and cache scope (07). |
@allow(read: Expr?, write: Expr?) | entity, object, field, operation | Policy expression; absent means allowed (06). |
@deny(read: Expr?, write: Expr?) | same | Explicit denial; evaluated after @allow. |
@load(batch | single) | field | Loader shape. Default is batch. single marks a field the executor may resolve one parent at a time. |
@page(cursor | offset) | field or query returning Page<T> | Pagination style. Default cursor. |
@cost(base: Int, perItem: Int?) | field, query, command, stream | Static cost hint for budgets. Default base: 0 on scalar and enum fields, 1 elsewhere; default perItem: 1 on pages, 0 elsewhere (06 §5). |
@idempotent(false) | command | The command takes no idempotency key: one sent with it is ignored, so every call runs and none is replayed (03 §4). |
@merge(serverWins | keepLocal | lww | crdtText | custom) | field | How an optimistic prediction for the field settles against the server's answer (08 §5). |
@simulate | command | The resolver honours ctx.simulate, so the command accepts dry runs. Without it, simulate: true is failed_precondition and the MCP bridge offers no .simulate tool (12 §6). |
@deprecated(reason: String?, sunset: Date?, replacement: String?) | anything | See 11. Tooling refuses removal before sunset. |
@lazy | field | Field is delivered in a later frame unless the shape asks for it eagerly. |
@partial | field | The field may fail without failing the operation; the client receives null and an entry in the frame's errors. |
@live(false) | query | Query cannot be subscribed to. |
@input(Type) | stream | Bidirectional stream; client items are of Type. |
@interface | object | Declares an interface. |
@range(min: Number?, max: Number?) | scalar, field, arg | Enforced before execution by the declared type (numbers and Decimals by value, strings and lists by length) on arguments and input fields the caller actually sent: a violating one is invalid_argument with a path, and no resolver runs. A default that is never sent is not checked, and @range on a result field is a hint only. |
@format(String, pattern: String?), @unit(String) | scalar, field, arg | Machine-readable hints for docs and agents; pattern is enforced on strings and must match the whole value. A server MAY refuse, before matching, a value longer than a limit it documents (the reference runtimes refuse over 10,000 characters). |
@example(value) | scalar, field, arg, any operation, and entity, object and input types | A sample value for docs, the explorer and agents. |
@version | entity field (Int, Long, String or Instant) | The entity's version for conditional commands (03 §4a). Bumped by the resolver on every write. |
@http(method: M, path: String, body: Name | "*"?, location: String?) | query, command | HTTP binding (04 §8). Queries bind GET or QUERY; commands bind POST, PUT, PATCH or DELETE. {name} path segments are arguments. |
@http(name: String) | operation argument, input field | Wire name: HTTP bindings read the member under this name instead of its own (04 §8). Exactly one argument, a non-empty string; within one operation or input type it may not be another member's name or wire name. Nothing else changes: /rayfold, MCP and results keep the schema name. |
@ordinal(Int) | field, enum value | Fixes the wire ordinal used by RB (09). Normally assigned by the lockfile. |
Unknown annotations MUST be rejected unless namespaced (@vendor.name(...)), in which case they are preserved on the node's own annotations list under the full vendor.name and ignored by conformant implementations that do not know them.
5. Policy expressions
Used by @allow / @deny. Grammar:
expr := or
or := and ("||" and)*
and := not ("&&" not)*
not := "!" not | cmp
cmp := primary (("==" | "!=" | "<" | "<=" | ">" | ">=" | "in") primary)?
primary := literal | path | call | "(" expr ")"
path := name ("." name)*
call := name "(" (expr ("," expr)*)? ")"Roots available in scope: viewer (the authenticated principal, shape defined by the server), args (operation or field arguments), this (the current entity or object, for field and entity policies) and bare names, which resolve to this.<name> on entities/objects and to args.<name> on operations. Built-in calls: has(list, value), len(x), now(). Expressions are pure. A path step reads only a member of an object, so tags.length or viewer.constructor is missing. A missing path evaluates to null. == and != treat a missing path and null as equal, ordering comparisons with null are false, and in tests list membership. Expressions are total but for one case: ordering two values that cannot be ordered is an evaluation error, which fails closed (06 §3).
6. Generic Page<T>
Page<T> is a built-in object:
object Page<T> { items: [T], cursor: String?, hasMore: Boolean, total: Int? }
input PageArgs { first: Int = 20, after: String?, offset: Int? }A field or query returning Page<T> MUST accept a page: PageArgs argument or the individual first/after arguments. A server MUST cap first (default cap 200) before the resolver sees it; returning no more than first items is then the resolver's obligation, and the runtime does not enforce it.
7. Reserved names
Any name starting with __ or $ (of a type, field, argument, operation or enum value), as §1 says, $type included; subscribe, manifest, simulate and sync as operation names. viewer, args and this are the roots of policy expressions (06) rather than reserved names: a field or operation may carry any of them.
8. Validation rules (normative)
An IR is valid when:
- Every referenced type exists; no name is defined twice (types, errors, events and views share one namespace with operations in a second namespace). Within one owner no member is named twice: the fields of a type, the arguments of an operation or field, the values of an enum, the members of a union.
- Every
entityhasid: ID(non-null). inputtypes reference only scalars, enums and inputs. Operation arguments, and the type a stream's@inputnames, likewise.- Operation results and entity/object fields reference only entities, objects, scalars, enums, unions and
Page<T>; astreamresult may also be aneventtype (§2.6). throwsreferences onlyerrordefinitions;emitsonlyeventdefinitions.- Every
viewreferences an existing type and every selected field exists on it (recursively). - Every
@allow/@denyexpression parses and references only paths rooted in the allowed roots. @pageappears only on fields and queries returningPage<T>;@inputonly on streams;@liveonly on queries.- Every entity, object and union type is reachable from at least one operation, event or view (unreachable types are a warning, not an error).
- Reference cycles between entities are allowed; the executor bounds depth per 02 §5.
@http(name: String)appears only on operation arguments and input fields, withnameits only argument and a non-empty string; within one operation's arguments, or one input type's fields, no wire name equals another member's name or wire name.- Every name in the IR is a name as §1 defines it and is not reserved (§7), whether text, a builder, an importer or a lock file produced it.
9. IR
The IR is a JSON document: the schema after parsing, with every default resolved and every reference checked. The manifest serves it, RB derives its key dictionary from it, and the schema hash is taken over it, so it is given here in full and not by reference to any implementation. An implementation that produces this structure from the same schema text produces the same hash. That is the only way a third party can join a fleet.
Two rules apply throughout, and they keep the hash stable:
- An optional member appears only when it has a value. A
descriptionnobody wrote is absent, notnullor"". - A flag appears only when true.
builtin,interface,eagerandpartialare present-and-trueor absent.
9.1 Document
| Member | Always | Meaning |
|---|---|---|
rayfold | yes | "0.1" |
types | yes | type name → TypeDef |
ops | yes | operation name → OpDef |
views | yes | "Type.name" → ViewDef |
extensions | no | vendor data, namespaced by the vendor |
extensions is excluded from the hashed form. Everything else is included. A conformant implementation ignores vendor data it does not know (§4). If the hash moved with that data, two servers offering the same conversation would look different, and a gateway that strips vendor metadata would look like a schema change.
9.1a The built-in definitions
Every IR contains these before a single line of schema is read, each carrying builtin: true. They are part of what is hashed, so an implementation that omits them matches no other implementation's hash. They are the first thing to get right, and the first thing to check when a hash does not match.
- The twelve scalars of §2.4, each
{kind: "scalar", builtin: true, annotations: []}:ID,String,Int,Long,Float,Boolean,Decimal,Instant,Date,Duration,Bytes,JSON. Page, anobjectwithtypeParams: ["T"]and four fields, in this order and with these ordinals:items: [T](1),cursor: String?(2),hasMore: Boolean(3),total: Int?(4).PageArgs, aninputwith three fields:first: Intdefaulting to20(1),after: String?(2),offset: Int?(3).
A schema may not redefine any of them (§8 rule 1).
9.2 TypeRef
A reference to a type, at a field, an argument or a return position.
| Member | Always | Meaning |
|---|---|---|
kind | yes | "named" or "list" |
nullable | yes | whether null is a value here |
name | on named | the type's name |
args | no | type arguments, a list of TypeRef (Page<Book> is {kind:"named", name:"Page", args:[Book]}) |
of | on list | the element's TypeRef |
9.3 TypeDef
Every type carries kind, name and annotations; description when written; builtin when true. The rest is by kind:
kind | Also carries |
|---|---|
entity | fields (FieldDef), implements (interface names, possibly empty) |
object | fields; typeParams when the type is generic; interface when true |
input | fields |
error | fields |
event | fields |
enum | values (EnumValueDef) |
union | members, the member type names |
scalar | nothing further |
9.4 OpDef
| Member | Always | Meaning |
|---|---|---|
kind | yes | "query", "command" or "stream" |
name | yes | |
description | no | |
args | yes | a list of ArgDef, possibly empty |
returns | yes | TypeRef |
throws | yes | declared error type names, possibly empty |
emits | yes | declared event type names, possibly empty |
annotations | yes | possibly empty |
9.5 ViewDef and Shape
A ViewDef is type, name and shape. A Shape is one member, items, a list of shape items. Each item carries kind and then:
kind | Also carries |
|---|---|
field | name; alias, args, shape when present; eager, partial when true |
spread | type, view |
on | type, shape |
defer | shape; label when present |
A shape item's args is an object of argument name → value, where a variable reference is {"$var": "name"}. Shape text and its canonical form are 02 §3; this is the parsed form the IR carries.
9.6 FieldDef, ArgDef and EnumValueDef
| Member | FieldDef | ArgDef | EnumValueDef |
|---|---|---|---|
name | yes | yes | yes |
description | when written | when written | when written |
type | yes | yes | — |
args | yes, possibly empty | — | — |
default | when the field is an input field with one | when declared | — |
annotations | yes, possibly empty | yes, possibly empty | yes, possibly empty |
ordinal | yes | — | yes |
ordinal is a stable integer assigned at first publication and recorded in rayfold.lock.json; ordinals are reserved for a future compact-struct encoding (09 §3), and rayfold check protects them against changing.
9.7 Annotation and its values
An annotation is name and args, an object of argument name → value. Values are JSON, except that four things which are not JSON are carried as single-member tagged objects:
| Tag | Carries |
|---|---|
{"$ident": "..."} | a bare identifier, such as cursor in @page(cursor) |
{"$duration": 60000} | a duration, in milliseconds (the schema's 60s and 60000 are one value) |
{"$expr": ...} | a policy expression, as the AST of §5 |
{"$type": ...} | a TypeRef, such as the argument of @input(Type) |
A policy expression node is one of: {"k":"lit","v":...}; {"k":"path","root":"viewer"\|"args"\|"this","path":[...]}; {"k":"bin","op":...,"l":...,"r":...}; {"k":"not","e":...}; {"k":"call","fn":...,"args":[...]}; {"k":"list","items":[...]}. A redacted manifest (12 §5.6) replaces an allow or deny annotation's args with {} and leaves the annotation itself in place, so a reader still learns that a policy exists without learning what it tests.
Canonical JSON
The schema hash is the SHA-256 of the canonical IR, exposed in the manifest so clients can detect drift, and the same form is what an idempotency record is scoped and bound by (12 §4.2). Two implementations that write it differently do not fail loudly. They stop recognising each other's schemas and each other's retries. The form is therefore given exactly:
- Object keys are sorted ascending by UTF-16 code unit. Not by code point: an astral character is a surrogate pair beginning
0xD800-0xDBFF, so it sorts beforeU+E000-U+FFFFrather than after. Not by any locale's collation either. - No insignificant whitespace: nothing between a key and its
:, its value, or the,that follows. - A member whose value is undefined is omitted, as JSON has no such value. Null is a value and is kept.
- Strings carry
\"and\\; the short forms\b,\f,\n,\r,\tforU+0008,U+000C,U+000A,U+000D,U+0009; and\u00xxin lower-case hexadecimal for any other character belowU+0020. Every other character is written literally,U+007FandU+2028/U+2029included. - An unpaired surrogate is written
\udxxxin lower-case hexadecimal. It has no UTF-8 encoding, so writing it literally would contradict the encoding this form is defined in; escaping it keeps the text both valid JSON and encodable, and keeps the hash a function of the value. - Numbers are as written, except where 12 §4.2 applies, which writes them in the one form every implementation derives from the value.
- The text is encoded as UTF-8 before hashing.