Skip to content

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: // line and /* 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\n counts 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 be PascalCase, fields and operations camelCase. 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 | stream

2.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
KindSemantics
querySafe and idempotent. Cacheable per 07. Shapeable. May be requested live when the live extension is present.
commandChanges 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).
streamProduces 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:

AnnotationOnMeaning
@cache(maxAge: Duration, scope: public | private, swr: Duration?)entity, queryFreshness and cache scope (07).
@allow(read: Expr?, write: Expr?)entity, object, field, operationPolicy expression; absent means allowed (06).
@deny(read: Expr?, write: Expr?)sameExplicit denial; evaluated after @allow.
@load(batch | single)fieldLoader 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, streamStatic 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)commandThe 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)fieldHow an optimistic prediction for the field settles against the server's answer (08 §5).
@simulatecommandThe 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?)anythingSee 11. Tooling refuses removal before sunset.
@lazyfieldField is delivered in a later frame unless the shape asks for it eagerly.
@partialfieldThe field may fail without failing the operation; the client receives null and an entry in the frame's errors.
@live(false)queryQuery cannot be subscribed to.
@input(Type)streamBidirectional stream; client items are of Type.
@interfaceobjectDeclares an interface.
@range(min: Number?, max: Number?)scalar, field, argEnforced 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, argMachine-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 typesA sample value for docs, the explorer and agents.
@versionentity 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, commandHTTP 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 fieldWire 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 valueFixes 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:

  1. 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.
  2. Every entity has id: ID (non-null).
  3. input types reference only scalars, enums and inputs. Operation arguments, and the type a stream's @input names, likewise.
  4. Operation results and entity/object fields reference only entities, objects, scalars, enums, unions and Page<T>; a stream result may also be an event type (§2.6).
  5. throws references only error definitions; emits only event definitions.
  6. Every view references an existing type and every selected field exists on it (recursively).
  7. Every @allow/@deny expression parses and references only paths rooted in the allowed roots.
  8. @page appears only on fields and queries returning Page<T>; @input only on streams; @live only on queries.
  9. Every entity, object and union type is reachable from at least one operation, event or view (unreachable types are a warning, not an error).
  10. Reference cycles between entities are allowed; the executor bounds depth per 02 §5.
  11. @http(name: String) appears only on operation arguments and input fields, with name its 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.
  12. 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 description nobody wrote is absent, not null or "".
  • A flag appears only when true. builtin, interface, eager and partial are present-and-true or absent.

9.1 Document ​

MemberAlwaysMeaning
rayfoldyes"0.1"
typesyestype name → TypeDef
opsyesoperation name → OpDef
viewsyes"Type.name" → ViewDef
extensionsnovendor 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, an object with typeParams: ["T"] and four fields, in this order and with these ordinals: items: [T] (1), cursor: String? (2), hasMore: Boolean (3), total: Int? (4).
  • PageArgs, an input with three fields: first: Int defaulting to 20 (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.

MemberAlwaysMeaning
kindyes"named" or "list"
nullableyeswhether null is a value here
nameon namedthe type's name
argsnotype arguments, a list of TypeRef (Page<Book> is {kind:"named", name:"Page", args:[Book]})
ofon listthe element's TypeRef

9.3 TypeDef ​

Every type carries kind, name and annotations; description when written; builtin when true. The rest is by kind:

kindAlso carries
entityfields (FieldDef), implements (interface names, possibly empty)
objectfields; typeParams when the type is generic; interface when true
inputfields
errorfields
eventfields
enumvalues (EnumValueDef)
unionmembers, the member type names
scalarnothing further

9.4 OpDef ​

MemberAlwaysMeaning
kindyes"query", "command" or "stream"
nameyes
descriptionno
argsyesa list of ArgDef, possibly empty
returnsyesTypeRef
throwsyesdeclared error type names, possibly empty
emitsyesdeclared event type names, possibly empty
annotationsyespossibly 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:

kindAlso carries
fieldname; alias, args, shape when present; eager, partial when true
spreadtype, view
ontype, shape
defershape; 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 ​

MemberFieldDefArgDefEnumValueDef
nameyesyesyes
descriptionwhen writtenwhen writtenwhen written
typeyesyes—
argsyes, possibly empty——
defaultwhen the field is an input field with onewhen declared—
annotationsyes, possibly emptyyes, possibly emptyyes, possibly empty
ordinalyes—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:

TagCarries
{"$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 before U+E000-U+FFFF rather 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, \t for U+0008, U+000C, U+000A, U+000D, U+0009; and \u00xx in lower-case hexadecimal for any other character below U+0020. Every other character is written literally, U+007F and U+2028/U+2029 included.
  • An unpaired surrogate is written \udxxx in 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.

Released under the Apache-2.0 license.