Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

runtime

A declarative application platform. You describe an application; the runtime serves it.

There is no build step. The runtime is an interpreter for a declarative model — an application is a directory of YAML, and the runtime reads it and serves it. Nothing is generated and nothing is compiled per application.

Three consequences follow, and most of what is surprising about the product comes from one of them:

  • Deploying an application builds nothing. One runtime image serves every application. A security patch is one rebuild, not one per customer.
  • The model is text. It diffs, it reviews, it merges — and a language model can read and write it, which a binary project file cannot.
  • There is a per-request cost. Interpreting is slower than a hand-written handler, on the order of tens of microseconds. Everything that can be is compiled once at model load rather than per request.

Where to start

You areStart here
New to thisGetting started
Building an applicationRuntime
Running the platformDeployment
Looking for what changedReleases

What it gives you

From roughly forty lines of YAML: a database schema, a REST API with pagination and conditional writes, an OpenAPI document generated per caller, row- and field-level access control compiled into every query, a Lua sandbox for the logic a model cannot express, and business metrics over OpenTelemetry.

None of that is scaffolded into your project. It is what the runtime does with your model, and changing your mind is an edit rather than a regeneration.

What this documentation is not

The reasoning behind the runtime — the architecture decisions, the internal invariants, why a particular trade-off went the way it did — lives with the source, not here. This is documentation for using the product.

Getting started

Ten minutes from nothing to an API answering requests.

What you need

An OCI engine — podman or docker — and the three repositories checked out beside each other:

runtime/      the interpreter
deployment/   how it is run
app/          an example application

Start everything

cd deployment
make serve     # builds the runtime image, starts Postgres and the runtime
make apply     # creates the tables from ../app/northwind

make serve before make apply is not a mistake — it is worth seeing what the runtime does when the database has no schema yet:

$ curl "localhost:9090/readyz?verbose"
[+] ping ok
[+] database ok
[-] model failed: this database was provisioned from a different model; run `runtime db apply`
readyz check failed

Readiness refuses traffic and names the fix. Liveness stays 200, because restarting would not create the schema — it would only add a cold start. That distinction is the whole reason there are two probes.

Ask it something

$ curl localhost:8080/api/v1/sales/Customer
{"data":[],"next":null}

$ curl localhost:8080/api-doc/openapi.json

The OpenAPI document is generated for the credentials you presented. Entities and fields your token cannot reach are absent from it — so it answers “what can I do”, not “what exists”.

Change the model

Open app/northwind/modules/sales/security.yaml and give the Guest role something new. Then, without restarting anything:

$ curl -X POST localhost:9090/_admin/model/reload
{"outcome":"reloaded","model":"northwind","version":"0.1.0","flows":1}

The next request uses the new policy. That is the interpreter premise paying off: changing an application is an edit and a reload, not a build and a deploy.

Try changing domain.yaml instead — adding an attribute, say — and the reload refuses:

{
  "outcome": "schema-changed",
  "detail": "this model implies a different database schema…"
}

Reload swaps what needs no migration. A schema change is runtime db apply, and it stays that way on purpose.

Break it on purpose

The error messages are the product, so it is worth seeing one. Put a precision on a string attribute:

$ runtime model validate app/northwind
error[M203]: `precision` is not a parameter of type `string`
  --> modules/sales/domain.yaml (entities[0].attributes[1])
  help: remove it, or change the type to `decimal`

Every diagnostic has a stable code, a location, and — where there is an answer — what to write instead.

Next

Your first application

A minimal application, built up one file at a time. Nothing here is scaffolded — every file is one you write.

The smallest thing that runs

helpdesk/
  app.yaml
  modules/support/module.yaml
# app.yaml
schemaVersion: 1
name: helpdesk
version: 0.1.0
modules: [support]

userRoles:
  - name: Agent
    grants: [support.Agent]
# modules/support/module.yaml
schemaVersion: 1
name: support
runtime model validate helpdesk

It validates, and serves nothing at all. Modules are listed rather than discovered, because a module on disk that nobody listed is almost always a half-finished rename — and failing loudly beats silently serving half a model.

Something to store

# modules/support/domain.yaml
schemaVersion: 1
module: support

entities:
  - name: Ticket
    description: One customer problem.
    attributes:
      - { name: subject, type: string, maxLength: 200, required: true }
      - {
          name: status,
          type: enum,
          values: [Open, Waiting, Closed],
          default: Open,
        }
      - { name: reporterEmail, type: string, maxLength: 254 }
      - { name: urgent, type: boolean, default: false }
    indexes:
      - { attributes: [status] }
runtime db plan helpdesk    # what it would do
runtime db apply helpdesk   # do it

db plan is safe against production and worth running there — it names every change that would take a lock or lose data.

Somebody to see it

An entity nobody can reach is invisible. Access is denied by default, which is the only safe direction for a rule somebody may forget to write:

# modules/support/security.yaml
schemaVersion: 1
module: support

roles:
  - name: Agent
    access:
      - entity: support.Ticket
        allow: [read, create, write]
        constraint: reporterEmail = $currentUser.email

That constraint is compiled into the WHERE clause of every query an Agent issues. Not a filter applied afterwards — there is no code path that can forget it.

runtime serve helpdesk

Logic the model cannot express

“A ticket cannot be closed while it is waiting on the customer” is not something a type system says. It is a flow:

# modules/support/flows.yaml
schemaVersion: 1
module: support

flows:
  - name: CloseTicket
    entity: support.Ticket
    allow: [Agent]
    script: flows/close-ticket.lua
    parameters:
      - { name: resolution, type: string, maxLength: 500, required: true }
-- modules/support/flows/close-ticket.lua
if row.status == "Waiting" then
  runtime.fail("this ticket is waiting on the customer; chase it or reopen it first")
end

local closed = runtime.update("support.Ticket", row.id, { status = "Closed" })
runtime.log("closed " .. row.id .. ": " .. params.resolution)

return { status = closed.status }
curl -X POST localhost:8080/api/v1/support/Ticket/$ID/CloseTicket \
  -d '{"resolution":"restarted it"}'

runtime.fail is the script refusing — a 422 carrying your message, because you wrote that message for the caller. An error() would be a 500 carrying nothing.

Something to count

# modules/support/metrics.yaml
schemaVersion: 1
module: support

metrics:
  - name: support.tickets.written
    description: Tickets created or changed, by status.
    entity: support.Ticket
    on: [create, write]
    labels: [status, urgent]

No code. The runtime maintains it as rows commit, and it arrives wherever your platform team sends OpenTelemetry.

Try labelling by subject instead and the model stops loading — labels are restricted to enum and boolean, the only types whose value set your model declares. Metrics explains why, and what to do when the restriction is in your way.

What you have

Six files. A database schema, a REST API with pagination and conditional writes, row-level access control, a business rule in Lua, and a metric — with no generated code to maintain and nothing to regenerate when you change your mind.

Authoring with an LLM

The model is text with a published schema, and that is not an accident — it is the bet the product is built on. A binary project file cannot be diffed, reviewed, or written by a language model. This one can.

Give it three things

The schema. runtime model schema prints it. It is generated from the same types that parse your model, so it is exactly what the runtime accepts — not a description of it.

runtime model schema > app-model.schema.json

An example. The app repository’s northwind is small, complete, and exercises every part of the format. A model to imitate is worth more than a specification to infer from.

The error messages. This is the part people skip, and it is the one that makes the difference. Do not ask for a model and hope; ask for a model, run runtime model validate, and give the output back:

$ runtime model validate draft
error[M210]: `companyName` is a `string`, which has no bounded set of values
  --> modules/sales/metrics.yaml (metrics[0].labels)
  help: a label value becomes part of a time series' identity, so only `enum`
        and `boolean` attributes may be labels…

Every diagnostic names the file, the path, what is wrong and usually what to write instead. That is a loop a model can close on its own, and it is why the diagnostics get as much care as the features.

What works well

  • Domain modelling. Entities, attributes, associations, indexes. This is the part LLMs are genuinely good at, and the validator catches what they get wrong.
  • Filling in a shape. “Here is domain.yaml, write me the security.yaml with a role per department.” Mechanical and tedious, which is the ideal case.
  • Explaining an existing model. The whole thing is text, so it fits in a context window and reads as prose.

What to check by hand

Anything in security.yaml. A constraint that is subtly too permissive validates perfectly. runtime serve plus curl -H "Authorization: Bearer $TOKEN" .../api-doc/openapi.json shows what a role can actually reach — which is the check worth doing, because it asks the same policy compiler the requests do.

Decimals. An LLM will write precision: 10, scale: 2 without thinking about whether ten digits is enough for your largest figure. Validation cannot know that.

onDelete. The default is restrict, which is the safe one. A model that specifies cascade everywhere is one that will one day delete more than anybody intended.

What it cannot do

The schema describes the format, not your domain. It will happily let a model generate a beautiful, internally consistent application that models the wrong business. Validation says the YAML is correct; it does not say the model is right.

A note on the shape of the format

Two decisions exist specifically to make this work, and they are worth knowing because they look arbitrary otherwise:

  • No flatten anywhere in the format. Attribute parameters are flat siblings (type: string, maxLength: 120) rather than nested, because the nested form would make the schema stop rejecting unknown fields — so a typo would be caught by neither the parser nor the editor.
  • Every file names its own module: and schemaVersion:. Redundant with the directory it is in, and checked against it, so a file generated in isolation cannot be silently misfiled.

Troubleshooting

The runtime refuses things deliberately and often. Most of what looks like a fault is a refusal with a reason, so the first question is usually “what is it telling me” rather than “what is broken”.

It will not start

no database configuredRUNTIME_DATABASE_URL is unset. Every command but model validate and model schema needs it.

RUNTIME_OIDC_ISSUER and RUNTIME_OIDC_AUDIENCE must be set together — half-configured authentication is refused on purpose. An issuer with no audience accepts tokens minted for every other service that provider serves.

no identity provider is configured and the model declares no anonymous role — nothing could ever reach this application. Either configure OIDC, or give the model an auth.anonymousRole.

A flow could not be compiled — a script has a syntax error. Compiling happens at start-up so this is a failed deploy rather than a 500 for whoever calls that flow first. The message names the file and line.

Readiness fails

Always ask it:

$ curl "localhost:9090/readyz?verbose"
[+] ping ok
[+] database ok
[-] model failed: this database was provisioned from a different model; run `runtime db apply`
CheckFailing means
databaseThe pool cannot reach PostgreSQL, or the query timed out
modelThe database was provisioned from a different model
drainingA termination signal arrived; this is normal during a shutdown

Liveness staying 200 while readiness fails is correct, not a bug. A restart does not create a schema or bring a database back — it only adds a cold start to an outage already in progress.

Requests fail

401 with the bearer token is not valid — every rejection reads the same on purpose: the difference between “expired” and “bad signature” is useful to an attacker and to nobody else. The real reason is in the log, on the runtime::audit target.

403 on everything — the token’s claim values map to no user role. A claim value with no mapping in app.yaml grants nothing, which is the safe direction to fail. Check auth.roleMapping against what your provider actually sends.

404 on a row you know exists — the row is outside your role’s constraint. Hidden and missing are deliberately indistinguishable, because answering differently would let anyone confirm an identifier by asking for it. Fetch /api-doc/openapi.json with the same token to see what it can reach.

428PATCH and DELETE need an If-Match. Read the row first and send back its ETag; or send If-Match: * to overwrite deliberately.

409 on a write — somebody changed the row since you read it. Re-read and retry. This is the mechanism working.

422 naming a field you are sure exists — with the detail “is not a field of this entity”, the field exists and your role cannot read it. That message is identical to the one for a typo, deliberately, so an error cannot be used to enumerate the schema.

A flow misbehaves

500 with no detail — the script called error(), or hit a bug. The message is in the log, not the response, because a script’s author and a script’s caller are usually different people. Use runtime.fail("...") for anything the caller should read.

cannot assign to the global — Lua makes undeclared variables global silently, which in a sandbox leaks state between parts of a script. Use local.

io, os, require are nil — they are absent, not restricted. A flow cannot read a file, run a process or make an HTTP request. That is the boundary, not a missing feature.

The script reaches no rows — it runs as whoever called it. If the caller cannot see a row, neither can the script. That is the design; see Flows.

the script used more instructions than it is allowed — a runaway loop. Wrapping it in pcall will not help: the budget poisons the interpreter and the runtime checks that after the call.

A metric never appears

Nothing shows up at all — metrics leave over OTLP and are exported on an interval, by default every sixty seconds. Wait, then look again.

The model will not load — a label on a string, decimal or datetime is refused. Only enum and boolean may be labels; see Metrics for why, and what to do instead.

It reads zero forever — check on:. A metric with an empty on list counts nothing, and validation warns about it (M904).

Reload refuses

outcomeMeaning
schema-changedThe model needs a migration. Run runtime db apply and deploy.
invalidThe bundle does not validate, or a script does not compile.

Both leave the running model untouched, which is the point of validating before swapping rather than after.

Nothing above fits

runtime config prints what configuration actually took effect. runtime model validate reports every problem in a bundle at once. Between them, most “it does not work” turns into a specific message — and if that message is unclear, that is a bug worth reporting.

Runtime

Reference for each part of an application model. If you are new, start with Getting started instead.

An application is a directory

myapp/
  app.yaml                      what it is called, who may log in
  modules/sales/
    module.yaml                 the module's manifest
    domain.yaml                 what is stored
    security.yaml               who may see and change it
    flows.yaml                  logic the model cannot express
    flows/approve-order.lua     ...and the script
    metrics.yaml                what to count

Only app.yaml and one module.yaml are required. Everything else is added when you need it.

FileReference
domain.yamlModelling a domain
security.yamlSecurity
flows.yamlFlows
metrics.yamlMetrics
The API the runtime serves

Modules

A module is a bounded context: its own entities, its own roles, its own flows. app.yaml lists the modules that make up an application and maps module roles onto the roles your organisation actually has.

The indirection earns its keep — a module ships with a sensible permission model without knowing anything about the organisation deploying it.

Point your editor at the schema

Do this before writing a line. Every file starts with a modeline:

# yaml-language-server: $schema=../../runtime/assets/schema/app-model.schema.json

With the YAML extension installed you get completion on every field, inline errors, and the documentation for each option as you type. The schema is generated from the same types that parse your model, so it cannot drift from what the runtime actually accepts. runtime model schema prints it.

Read the errors

They are the product, not an afterthought:

$ runtime model validate myapp
error[M203]: `precision` is not a parameter of type `string`
  --> modules/sales/domain.yaml (entities[0].attributes[1])
  help: remove it, or change the type to `decimal`

Validation runs in three passes and which one fails determines the message: parse (is it YAML, does it match the schema), resolve (does that entity exist, does that attribute exist on it), check (is this expression well-typed against the model).

Every diagnostic has a stable code, and Diagnostics lists all of them. M2xx is a semantic problem; M9xx is a warning — legal, and nearly always unfinished.

Two things that surprise people

Money is never a number. decimal crosses every boundary as a string — in JSON and in Lua. A binary float cannot represent 0.10, and a total that is out by a cent is a total nobody trusts. Sending 12.10 as a JSON number is an error, not a rounding.

Writes need an If-Match. PATCH and DELETE without one are refused with 428. Two people editing the same record is not a race condition, it is a Tuesday, and last-write-wins loses one of the two changes with no error at all.

Modelling a domain

domain.yaml is the shape of the thing. Every entity becomes a table, every attribute a column, every association a foreign key or a junction table.

schemaVersion: 1
module: sales

entities:
  - name: Customer
    description: A company that places orders.
    attributes:
      - { name: companyName, type: string, maxLength: 120, required: true }
      - {
          name: tier,
          type: enum,
          values: [Bronze, Silver, Gold],
          default: Bronze,
        }
      - { name: creditLimit, type: decimal, precision: 12, scale: 2 }
      - { name: active, type: boolean, default: true }
    indexes:
      - { attributes: [companyName], unique: true }

Naming is enforced, not suggested

ThingCasingExample
moduleslower_snake_casesales
entitiesUpperCamelCaseCustomer
attributeslowerCamelCasecompanyName
associationsUpperCamelCaseCustomer

Validation rejects anything else. A format that permits two spellings gets both, and then every reader has to know which one this codebase chose.

Names reach SQL as snake_casecompanyName is the company_name column, and that is the name you see in API responses too.

Types

TypeParametersNotes
stringmaxLengthBounded text
textUnbounded
integermin, max32-bit
longmin, max64-bit
decimalprecision, scaleThe only correct type for money
boolean
datetimeRFC 3339, stored as timestamptz
dateYYYY-MM-DD
enumvaluesThe only labellable type besides boolean

There is deliberately no float. A binary float cannot represent 0.10, and the one place people reach for a float is the one place it is unacceptable. A decimal default is written quoted — default: "0.00" — because YAML would otherwise parse it as a float and lose both the precision and the trailing zeros that carry the scale.

Associations

associations:
  - name: Customer
    kind: reference # a foreign key on this table
    target: sales.Customer
    required: true
    onDelete: restrict

kind is either reference — a foreign key on this table — or referenceSet, a many-to-many junction table.

referenceSet cannot be written yet. The junction table is created and nothing fills it: there is no API for adding or removing a link. Model a many-to-many as an entity of its own for now, with a reference to each side, which also gives you somewhere to put the attributes such a relationship usually turns out to need.

onDelete is the interesting field:

ValueWhat happens when the target is deleted
restrictThe delete is refused while references remain. Default.
cascadeThis row goes too.
setNullThe reference is cleared. Only if not required.

restrict is the default deliberately: deleting a customer who has orders is nearly always a mistake, and the cases where it is not should have to say so.

A cascade is walked by the runtime, not left to the database — each row it reaches is authorized in its own right, so a cascade that would remove something the caller may not delete refuses the whole request.

Renaming things

Say so. There are no hidden identifiers:

- { name: emailAddress, previousName: email, type: string }

Without that, renaming is indistinguishable from dropping one column and adding another — and dropped columns are renamed out of the way rather than deleted, so the data would survive invisibly. Worse than losing it, because nobody notices for a month.

If the runtime cannot tell a rename from a delete-plus-add, it refuses the migration and says so. No flag overrides that: --allow-drop means “I meant to lose this”, and a refusal means the planner cannot tell what you meant.

System attributes

Every entity gets these. You do not declare them and cannot write them:

id (UUIDv7), createdAt, changedAt, createdBy, changedBy, version.

createdBy and changedBy are the token’s subject, not anything in the request body — an audit trail a caller can write is not an audit trail. version is what ETag carries.

Checking it

runtime model validate myapp   # three passes: parse, resolve, check
runtime db plan myapp          # what provisioning would do

db plan is safe to run against production, and worth running there: it names every change that would take a lock or lose data.

Security

The part worth reading twice. A mistake here is not a bug, it is a disclosure.

Three levels, and the third is the one nothing else in your stack can do for you:

LevelThe question it answers
EntityMay this role read/create/write/delete this at all?
AttributeMay it see or change this particular field?
RowWhich rows of that entity does it see?

Roles are declared per module, granted per user

A module defines roles in its own terms. app.yaml maps them onto the roles your organisation actually has, and onto the claims your identity provider sends:

# app.yaml
userRoles:
  - name: SalesRep
    grants: [sales.Rep]

auth:
  roleClaim: groups
  roleMapping:
    acme-sales: SalesRep
  anonymousRole: Guest # remove this and anonymous requests get 401

The indirection earns its keep: a module ships with a sensible permission model without knowing anything about the organisation deploying it.

A claim value with no mapping grants nothing. A typo removes access rather than adding it, which is the safe direction to fail.

Entity and attribute access

# modules/sales/security.yaml
roles:
  - name: Rep
    access:
      - entity: sales.Customer
        allow: [read, write] # not create, not delete
        attributes:
          creditLimit: none # omitted from responses entirely
          tier: read # visible, never writable by this role

Anything not listed in allow is denied. There is no “allow everything” — the default is no access, because that is the only safe direction for a rule somebody may forget to write.

An attribute set to none is omitted, not nulled. A null says “this exists and is empty”; an absent key says nothing at all, and the difference is information the role is not entitled to. It also never enters the SELECT, so it cannot be logged or traced by accident.

Row constraints

- entity: sales.Order
  allow: [read, create, write]
  constraint: Customer/accountManagerEmail = $currentUser.email

That expression is compiled into the WHERE clause of every query this role issues. It is not a filter applied afterwards, and there is no code path that can forget it.

The / traverses an association. Order has no owner column of its own, so reachability is decided by the Customer it belongs to — which is exactly the case a “just add a tenant_id column” design cannot express.

What you can write: comparisons (=, !=, <, <=, >, >=), and, or, not, parentheses, paths through associations, and $currentUser.email, .subject or .name. Deliberately no functions and no arithmetic: this lives inside a security boundary, and a language you can read in one sitting is one you can be sure of.

What a caller sees when refused

SituationAnswer
No role grants the entity at all403
A row the constraint excludes404 — same as a row that is gone
A field the role cannot readAbsent from the response
Writing a field it may read but not write422, naming the field
Writing a field it cannot read422, “is not a field of this entity”

The last two are worth dwelling on. A field you can see but not change is named honestly, because you already know it exists. A field you cannot see reads exactly like a typo — otherwise the error message becomes a way to enumerate the schema one guess at a time.

404 for a hidden row is the same reasoning: if a hidden row answered 403 and a missing one 404, anyone could confirm an identifier by asking for it.

Checking your work

runtime serve myapp
curl -H "Authorization: Bearer $REP_TOKEN" localhost:8080/api/v1/sales/Order
curl localhost:8080/api-doc/openapi.json   # what *this* token can do

The OpenAPI document is generated per caller: entities and fields the token cannot reach are absent from it. It is the fastest way to see what a role actually has, and it is derived from the same policy compiler that answers the requests — so it cannot disagree with them.

It is not a security boundary. Omitting an endpoint hides it from a reader; it does not stop anybody calling it. Enforcement is in the query layer.

The API

Two routes, not two per entity. Adding an entity adds two endpoints with no rebuild.

GET    /api/v1/{module}/{entity}          list
POST   /api/v1/{module}/{entity}          create
GET    /api/v1/{module}/{entity}/{id}     fetch one
PATCH  /api/v1/{module}/{entity}/{id}     change
DELETE /api/v1/{module}/{entity}/{id}     remove
POST   /api/v1/{module}/{entity}/{id}/{flow}   run a flow
GET    /api-doc/openapi.json              what *this* token can do

A browsable reference at /api-doc

$ open http://localhost:8080/api-doc

Paste a bearer token into the bar at the top and the page re-renders as that principal. Try it with two different tokens — watching entities and fields appear and disappear is the quickest way to understand what your role can do, and it is something a normal API reference cannot show you.

“Try it out” sends requests as the same token, so what you read and what you call are the same principal.

The viewer ships inside the runtime, so it works with no internet access and makes no request to anyone else. The page and its script are the only two things the runtime serves without a token — a browser cannot send one until the page has asked you for it. The document itself is always authenticated.

The document describes you

GET /api-doc/openapi.json is generated against your token. Entities you cannot read are absent; fields you cannot read are absent from the schemas.

The usual single document is true about the system and false about you — you read it, write a client against a field, and find out at runtime that your role cannot see it. This one answers the question you actually had.

Pagination is a cursor

$ curl "localhost:8080/api/v1/sales/Customer?limit=50"
{"data":[…],"next":"01a0…"}

Pass next as ?after= for the following page, and loop until it is absent. There is no page number and no total: offset pagination over a filtered set skips rows when one is inserted underneath it, and a total means a second query nobody asked for.

limit is clamped. Asking for more than the maximum is not an error.

Following an association

?expand= attaches a referenced row to every row of a page:

$ curl "localhost:8080/api/v1/sales/Order?expand=Customer"
{"data":[{"order_number":"ORD-1","customer_id":"019f…",
          "Customer":{"company_name":"Ours Ltd","tier":"Gold"}}]}

Fifty orders and their customers is two queries, not fifty-one. The identifiers are collected from the page and fetched in one go, so the cost depends on how many associations you asked for and not at all on how many rows came back.

Comma-separate for several, and use a dot to go deeper:

$ curl "localhost:8080/api/v1/sales/OrderLine?expand=Order.Customer"

At most three deep, and twelve in total. Each one is a query, so the cap is what stops a long query string being an expensive request.

The expanded row appears under the association’s name, beside the <name>_id field rather than replacing it — so a client that only wanted the identifier keeps working.

It follows the same permissions you have

An expansion is a read of a second entity, and being allowed to see an order says nothing about being allowed to see the customer behind it. So the expansion is checked on its own account, with your credentials, and two things follow:

  • An entity you may not read at all is a 400, naming it. Your own API document already lists what you may read, so this tells you nothing new and saves you debugging a field that is quietly missing.
  • A row a constraint hides comes back as null, exactly as an unset reference does. It has to: distinguishing “hidden” from “absent” would let somebody confirm which rows exist by watching which shape came back.

Hidden fields are hidden inside an expansion too. If your role cannot read creditLimit on a customer, it is absent from the nested object for the same reason it is absent from a direct read.

What it does not do

Many-to-many. A referenceSet is refused by name. It is not readable at all yet — not even as a list of identifiers — so there is nothing to expand.

The reverse direction. Order declares Customer, so ?expand=Customer has a name to use. Customer declares nothing pointing back at its orders, so there is no name to ask for, and ?expand=Orders is not a thing you can write. Fetch them the other way round: list orders and filter by the customer.

Writes need a precondition

PATCH and DELETE without an If-Match are refused with 428:

$ curl localhost:8080/api/v1/sales/Customer/$ID          # read it
ETag: "1"

$ curl -X PATCH -H 'If-Match: "1"' … $ID                 # 200, new ETag "2"
$ curl -X PATCH -H 'If-Match: "1"' … $ID                 # 409, somebody was first

Two people editing the same record is not a race condition, it is a Tuesday, and last-write-wins loses one of the two changes with no error at all. If-Match: * overwrites deliberately.

There is no PUT. A role that cannot read every field cannot send a complete representation of a row, so the only honest full-replacement semantics would be “and blank everything I cannot see”. PATCH with a partial body is the only shape that composes with per-field access control.

Errors are problem documents

RFC 9457, with a stable type to match on and a detail to read. A rejected write names every bad field at once:

{
  "type": "https://…/problems/invalid-fields",
  "status": 422,
  "detail": "the request names fields that cannot be used; see `errors`",
  "errors": [
    {
      "field": "tier",
      "detail": "must be one of `Bronze`, `Silver`, `Gold`, not `Platinum`"
    },
    {
      "field": "total",
      "detail": "is not writable by this role; it can be read but not changed"
    }
  ]
}

Match on type. The detail may be reworded; the type will not.

StatusWhen
401No token, or one that did not validate
403Authenticated, not permitted
404No such row — or one you may not see. Deliberately the same
409A stale If-Match, or something is in the way
422Understood, cannot be done. Field problems are here
428A write with no If-Match

Vary: Authorization

On every response, because rows, fields and the whole OpenAPI document differ per token. If you put a shared cache in front of this, it must respect that header — otherwise it hands one customer’s rows to another.

Money

decimal is a string in JSON, in both directions:

{ "credit_limit": "1000.00" }

Sending 1000.00 as a number is a 422 explaining why: by the time it reaches the runtime it has already been through a binary float, and 0.10 is not representable in one. Your client should keep it a string all the way to whatever does the arithmetic.

Flows

For the things a declarative model cannot express. A flow is a Lua script with a signature: which entity it operates on, which roles may run it, what it takes.

# modules/sales/flows.yaml
flows:
  - name: ApproveOrder
    entity: sales.Order # the row is fetched before the script starts
    allow: [Manager] # which roles may invoke it
    script: flows/approve-order.lua
    parameters:
      - { name: note, type: string, maxLength: 200 }
if row.status ~= "Placed" then
  runtime.fail("an order can only be approved from `Placed`; this one is `" .. row.status .. "`")
end

local approved = runtime.update("sales.Order", row.id, { status = "Shipped" })
return { orderNumber = approved.order_number, status = approved.status }
curl -X POST localhost:8080/api/v1/sales/Order/$ID/ApproveOrder -d '{"note":"fine"}'

What is in scope

GlobalWhat it is
rowThe entity row, already filtered by the caller’s policy
paramsThe declared parameters, already validated
usersubject, email, name, roles
runtimeThe host API below

And the host API:

runtime.get(entity, id)              -- a row, or nil
runtime.list(entity, limit)          -- a page of rows
runtime.create(entity, fields)       -- returns the new row
runtime.update(entity, id, fields)   -- returns the changed row, or nil
runtime.delete(entity, id)           -- true if it went
runtime.log(message)                 -- to the application log
runtime.fail(message)                -- refuse, with a message for the caller

fail is not error

runtime.fail is the script refusing: a 422 carrying your message, because you wrote that message for the caller. error() is the script breaking: a 500 carrying nothing, because whatever it says was not written for them. It goes to the log instead.

Use fail for domain rules. Let error happen for bugs.

It runs as whoever called it

Not as the platform, and not as you. Every host call goes through the same policy a REST request does, with the same principal — so a script reaches exactly the rows its caller could reach by hand, and not one more.

There is no runAs: and no way to elevate. A flow that “just needs” to read one row the caller cannot see is a flow that has become a privilege escalation with a YAML file in front of it, and the cost of allowing it is that security.yaml stops being the answer to “what can this token do”.

The practical consequence: a flow that reports a total across rows the caller cannot itemise is not expressible today. Model it as a role they hold, or do it outside a flow.

What the sandbox stops

io, os, package, require, load, dofile, print, debug, collectgarbage and the raw* functions are absent — not restricted. You get string, table, math, and the usual base functions.

coroutine is absent too, and for a different reason than the others: a Lua hook is per-thread, so code inside a coroutine would run outside the instruction budget. It is a hole in the limits rather than a dangerous library, and it can be reopened once the hook is set per thread.

You also cannot create a global. Lua makes undeclared variables global silently, which in a sandbox is a way to leak state between parts of a script; use local. The error tells you so.

count = 0            -- error: cannot assign to the global `count`
local count = 0      -- fine

Limits, and one honest gap

  • Instructions. A runaway loop is stopped in milliseconds. Wrapping it in pcall does not help: the budget poisons the interpreter, and the runtime checks that after the call rather than trusting what came back.
  • Memory. string.rep("x", 1e9) is one instruction and would otherwise be a gigabyte.
  • Wall clock. The request is bounded.

The gap: a script stuck inside a single long-running C function cannot be interrupted — the instruction hook never fires, so the wall clock cannot fire either. The memory cap catches the realistic cases. There is no hard CPU bound, and claiming one would be worse than saying so.

Money in Lua

Lua numbers are f64, and 0.10 is not representable in one. So a decimal arrives in your script as a string and must leave as one:

runtime.update("sales.Order", row.id, { total = "12.10" })  -- fine
runtime.update("sales.Order", row.id, { total = 12.10 })    -- error, naming the field

You will meet this in week one. The alternative is invoices that are wrong by a cent in month three.

Metrics

Business metrics, declared rather than instrumented. Nobody writes code to fill these in — which is the point, because the person who wants the number is rarely the person who can deploy a change to get it.

# modules/sales/metrics.yaml
metrics:
  - name: sales.orders.written
    description: Orders created or changed, by the status they ended up in.
    entity: sales.Order
    on: [create, write]
    labels: [status]

  - name: sales.orders.cancelled
    entity: sales.Order
    on: [create, write]
    when: status = 'Cancelled'

Three orders later, in whatever the platform team runs:

sales.orders.written_total{status="Placed"}     2
sales.orders.written_total{status="Cancelled"}  1
sales.orders.cancelled_total                    1

The fields

FieldWhat
nameLower case, dot-separated. Namespace it yourself
entityWhose commits it counts
oncreate, write, delete. Empty counts nothing
whenOptional condition on the committed row
labelsAttributes to break down by. Enums and booleans only

when uses the same expression language as a row constraint, but evaluated against the row that just committed rather than compiled into a query. So no traversal (Customer/tier) and no $currentUser: there is no query to traverse in, and no request to ask about. Validation refuses both rather than letting them silently never match.

Counters are emitted after the transaction commits, so a rolled-back write never appears.

Why labels are so restricted

A label value is part of a time series’ identity. Ten statuses is ten series; ten thousand customer names is ten thousand, multiplied by every other label, and again by every replica. The failure is slow, expensive, and lands on the platform team rather than on you.

So a label must be an enum or a boolean — the only types whose value set your model declares, and therefore the only ones whose cardinality can be checked by reading the file:

error[M210]: `companyName` is a `string`, which has no bounded set of values
  --> modules/sales/metrics.yaml (metrics[0].labels)
  help: a label value becomes part of a time series' identity, so only `enum`
        and `boolean` attributes may be labels…

The model does not load. That is deliberate: the alternative is discovering it in production, on the day of the traffic that caused it.

When the restriction is in your way

You want a breakdown by an enum on another entity — revenue by customer tier, counted on Order. Not available: a label cannot traverse. Denormalise the enum onto the row being written. That is a real cost and it is visible in your model, which is better than a metrics bill that is not.

You want a breakdown by something high-cardinality — by customer, by order number. Then the answer is not a metric at all. Every write is already in the audit stream with its entity and row identifier, and that goes to a log backend, which is built for exactly this. Ask your platform team for it.

What is not here yet

Counters only. No gauges, no histograms, and no value: naming an attribute to sum — so “orders placed” works and “revenue booked” does not. A metric on a delete can count but cannot label, because there is no row afterwards.

Deployment

You run the runtime; somebody else writes the applications. This section is about the process, not the model.

What you need

Four things, and only the first is required to start:

You provideVersionWithout it
PostgreSQL18 or laterThe runtime will not start. There is no other database option.
An OCI engineanyYou build from source instead; the image is the supported path.
An OIDC providerany, by discoveryEvery request is anonymous and every bearer token is rejected.
An OTLP endpointany collectorNo traces, metrics or logs leave the process. Not a start-up failure.

PostgreSQL 18 is a real floor, not a recommendation — the runtime provisions and migrates schemas itself and reads the catalogue back to check its work, and it uses what 18 provides to do it. Check before you plan a deployment; it is newer than what many organisations run by default.

Your identity provider is yours. The runtime speaks OIDC discovery and holds no vendor’s SDK, so Keycloak, Entra ID, Auth0, Okta and anything else that publishes a JWKS all work the same way. It never issues a token and has no user database. Until RUNTIME_OIDC_ISSUER is set, every caller is the model’s anonymous role — which is fine for a first look and is not a way to run anything.

Your telemetry backend is yours too. The runtime emits OTLP and nothing else: no Prometheus endpoint to scrape, no vendor agent, no exporter to choose. Point it at a collector and fan out from there. A collector it cannot reach is deliberately not a start-up error — see Observability.

What it is built on

Rust, compiled to one static-ish binary in a distroless image. That matters to you in three ways and no others: there is no runtime to install and no interpreter to patch separately, the container needs no shell and runs as non-root with a read-only filesystem, and memory use is flat and predictable enough that you can size a replica from one load test.

Application logic — flows — is Lua 5.4, sandboxed, with an instruction budget and a memory cap. That is a thing your developers will ask about; the short answer is that it is the only language today, a WebAssembly engine is designed for and not built, and a flow runs with the permissions of whoever called it and cannot escalate.

Beyond that, the library list is not something this documentation publishes. Naming them would imply a compatibility promise the project does not make, and none of them change what you have to run or configure.

What you are running

One stateless binary in one container image, plus PostgreSQL. The image contains no application — the runtime is an interpreter, so the model is mounted or fetched at start-up. One image serves every application, which makes a CVE in a dependency one rebuild rather than one per customer.

It writes nothing to its filesystem. Run it read-only:

podman run --read-only --cap-drop=ALL --security-opt=no-new-privileges \
  -v ./myapp:/model:ro \
  -e RUNTIME_DATABASE_URL=postgres://... \
  -e RUNTIME_ADMIN_ADDR=0.0.0.0:9090 \
  runtime:dev serve /model

There is no CMD in the image: serve needs a model and there is no sensible default for which one. A container started with no arguments says so rather than failing somewhere less obvious.

With compose

The deployment repository has compose files that run the whole thing, on any OCI engine:

make serve     # build the image, start Postgres, the runtime, and observability
make apply     # provision the database from the model
make logs      # follow the runtime
make down      # stop

Two compose files rather than one with profiles, because profiles are not supported identically across engines and “works with any container engine” is the point.

Two ports, and only one is yours to route

PortWhatExpose it?
8080End-user traffic. Authenticated.Yes
9090Health, introspection, reload. No auth.No

The admin port carries no authentication, and the only thing making that acceptable is that nothing routes to it. Do not put it behind an Ingress. It defaults to loopback for exactly that reason, and you have to widen it deliberately to run in a container at all.

What is not here yet

A Helm chart, and an operator with CRDs. Both are planned. What exists is deliberately the simplest thing that genuinely runs, so the Kubernetes story is designed against something working rather than guessed at.

Next

Configuration

Every variable is prefixed RUNTIME_, so env | grep RUNTIME_ is a complete answer. There is no configuration file: the environment is read once, at start-up, in the binary.

runtime config prints what actually took effect and exits — because “which value won” is a question asked at the worst possible moment.

Every variable

VariableDefaultNotes
RUNTIME_DATABASE_URLRequired to serve
RUNTIME_APP_ADDR0.0.0.0:8080End-user traffic
RUNTIME_ADMIN_ADDR127.0.0.1:9090Widen in a container; never route to it
RUNTIME_DATABASE_MAX_CONNECTIONS10See below
RUNTIME_DRAIN_DELAY5Seconds. See below
RUNTIME_OIDC_ISSUERBoth or neither
RUNTIME_OIDC_AUDIENCEBoth or neither
RUNTIME_OTLP_ENDPOINTUnset exports to stdout
RUNTIME_ENVIRONMENTdevelopmentTags every span, metric and log
RUNTIME_LOGinfotracing filter directives
RUNTIME_LOG_FORMATjsonOr pretty, for a laptop

The app and admin addresses must differ, and the loader refuses to start otherwise.

The pool size is the capacity knob

RUNTIME_DATABASE_MAX_CONNECTIONS is per process. The number to reason about is not this one but this one times the replica count, against PostgreSQL’s own max_connections. A value that looks modest per pod is how a scale-up takes the database down.

Requests that arrive with the pool saturated wait five seconds for a connection and then fail. That is deliberately shorter than the thirty-second request timeout: a brief slowdown should produce prompt errors that readiness and your metrics both show, not a pile of requests all waiting out the full timeout and outliving the slowdown that caused them.

Authentication is both or neither

RUNTIME_OIDC_ISSUER=https://login.example.com/realms/acme
RUNTIME_OIDC_AUDIENCE=runtime

Set one without the other and the runtime refuses to start. An issuer with no audience accepts tokens minted for every other service that provider serves — a different tenant, a different application — and it does so silently.

Keys are discovered from the issuer’s /.well-known/openid-configuration at start-up, so a misconfigured issuer stops a deploy rather than becoming a 500 on whoever calls first.

With neither set, no bearer token can be verified: every authenticated request is a 401 and only the model’s anonymousRole reaches anything. If the model declares no anonymous role either, the runtime refuses to start — a process that could serve nobody is the most expensive kind of healthy.

What the runtime never reads

There is no configuration file, no .env loading, and no remote configuration service. The .env.example in the runtime repository is the documented variable list, not a mechanism — nothing loads it.

Securing a deployment

What the runtime guarantees, what it leaves to you, and where the boundaries actually are.

What the runtime handles

  • Access control, compiled into every query. There is no code path that can forget a row constraint or return a field a role cannot read.
  • Token verification — asymmetric algorithms only, exp/iss/aud/sub all required, keys discovered from the issuer and cached.
  • The script sandbox — no filesystem, no network, no process, and an instruction and memory budget.
  • SQL injection, structurally: every identifier is quoted by the query builder and every value is a bound parameter. Entity and attribute names come from customer YAML, so this is not theoretical.

What you handle

The admin port

The single most important thing on this page. Port 9090 carries no authentication — health, introspection, and an endpoint that reloads the model. The only thing making that acceptable is that nothing routes to it.

  • Do not put it behind an Ingress, a Service of type LoadBalancer, or a hostPort.
  • It defaults to loopback. You have to widen it deliberately to run in a container; widen it to the pod, not to the network.
  • A NetworkPolicy that admits only the kubelet is worth writing.

Reload takes no request body precisely because of this: it re-reads a mounted path, so the worst an attacker on that port can do is make the runtime re-read a file they cannot change. That is a small blast radius by construction rather than by policy — but it is not zero, and it depends on the port staying unroutable.

TLS

The runtime speaks plain HTTP. Terminate TLS at your ingress, and use TLS to PostgreSQL — sslmode=require at minimum, verify-full if you can:

RUNTIME_DATABASE_URL=postgres://user:pass@host/db?sslmode=verify-full

Secrets

RUNTIME_DATABASE_URL contains a password. It arrives as an environment variable, so:

  • Mount it from a secret store rather than baking it into a manifest.
  • The runtime never logs it. Error messages name the variable, never the value, because an error message is the easiest way for a secret to reach a log aggregator.
  • runtime config prints the effective configuration — including that URL. It is a debugging command, not something to wire into a dashboard.

The model is code

A model bundle contains flow scripts, and scripts execute. Whoever can write to the mounted bundle can run code in the runtime’s process, as whoever calls the flow.

Treat the bundle as you would a deployment artefact: version controlled, reviewed, and mounted read-only. Do not give an application’s users write access to the volume it is served from.

Identity

Configure both RUNTIME_OIDC_ISSUER and RUNTIME_OIDC_AUDIENCE. The audience check is not decoration — without it, a token minted for any other service at the same issuer is accepted here.

Set auth.anonymousRole in the model only if you mean it. It is how a public read-only surface is expressed, and it is also how an application accidentally becomes public.

The blast radius of a sandbox escape

Worth stating plainly, because it is smaller than people assume and not zero.

A flow runs as its caller, so a script has no authority its caller lacks. An escape from the Lua sandbox gets an attacker what that caller could already have done through the REST API — not the database, and not other tenants’ data.

That is a reason the design is what it is, not a reason to relax the sandbox.

What is genuinely missing

Be aware of these before putting this in front of untrusted users:

  • No rate limiting and no per-caller quota. A body limit and a request timeout exist; nothing bounds how many requests one caller may make.
  • Single tenant. One model and one database per deployment. Isolation between customers is deployment-level, not runtime-level.
  • No audit log retention or tamper-evidence. The audit stream goes to stdout like everything else. Retention and integrity are your log pipeline’s job.
  • No hard CPU bound on a script. The instruction budget stops interpreted loops and the memory cap stops the realistic runaways, but a script inside a single long-running C function cannot be interrupted.

A checklist

[ ] Admin port not routable, NetworkPolicy in place
[ ] TLS terminated at the ingress; sslmode=verify-full to the database
[ ] Database credentials from a secret store, not a manifest
[ ] OIDC issuer and audience both set
[ ] anonymousRole absent, or deliberate
[ ] Model bundle mounted read-only, from a reviewed artefact
[ ] terminationGracePeriodSeconds above 35
[ ] Rate limiting at the ingress, since the runtime has none

Health and shutdown

A health endpoint is not an assertion about the process. It is an instruction to your orchestrator, and getting one wrong does not produce a wrong answer — it produces a restart loop, or a rolling deploy that drops requests.

Probes

EndpointAsksWhere
/livezShould I be killed and restarted?Both ports
/readyzShould traffic be routed to me?Admin only
/healthzEverything, for a humanAdmin only

Append ?verbose for the itemised version, in the format the Kubernetes API server uses:

$ curl "localhost:9090/readyz?verbose"
[+] ping ok
[+] database ok
[-] model failed: this database was provisioned from a different model; run `runtime db apply`
readyz check failed

A probe reads the status code. A human debugging at 03:00 reads the body, and “which check failed” should not require a log dive.

/livez never checks a dependency

This is the single most common and most expensive mistake in a Kubernetes deployment, so it is worth being explicit: liveness here checks only that the process is functioning. It does not touch the database, and it never will.

If it did, a database blip would restart every replica simultaneously — turning a dependency outage into a self-inflicted one at the exact moment the database can least afford a thundering herd of reconnects. A restart does not bring the database back; it only adds a cold start to an outage already in progress.

Dependency checks belong in readiness, where the consequence is “stop sending me traffic” rather than “kill me”.

livenessProbe:
  httpGet: { path: /livez, port: 9090 }
readinessProbe:
  httpGet: { path: /readyz, port: 9090 }

Set terminationGracePeriodSeconds above 35

The one configuration mistake that silently undoes real work.

A termination signal starts a drain, not a shutdown:

  1. The signal arrives. Readiness starts failing immediately — that is what tells the endpoints controller to stop routing here.
  2. The listener keeps serving for RUNTIME_DRAIN_DELAY (5s by default), because removing a pod from a Service is eventually consistent and traffic is still arriving.
  3. It stops accepting and finishes what is in flight.
  4. The admin listener stops last, so liveness answers throughout.

Why the delay exists: Kubernetes sends SIGTERM and removes the pod from its Service concurrently, and the removal has to reach every kube-proxy and every ingress. A process that stops accepting the moment the signal lands spends that entire window refusing connections still being routed to it — which is a 502 to somebody, on every rolling deploy, for as long as nobody looks closely enough to notice.

So a shutdown legitimately takes up to the drain delay plus the request timeout: 5 + 30 with the defaults. Kubernetes’ default grace period is 30, which is less.

terminationGracePeriodSeconds: 45

Left at the default, the pod is killed mid-drain and the mechanism is worse than not having it: the delay is spent and the in-flight work is thrown away anyway.

Step 4 matters for the same reason. A liveness probe that got connection-refused at second three of a thirty-second drain would report the pod as dead, and the kubelet may kill it — throwing away exactly the work the drain existed to protect.

What a saturated pool looks like

Not a hang. Requests wait five seconds for a connection and then fail, which is deliberately shorter than the request timeout. You see errors and a readiness that still passes — the database is reachable, there are just not enough connections. Configuration has the knob.

Observability

OpenTelemetry, and only OpenTelemetry. Traces, metrics and logs all leave over OTLP, so whatever you already run is what you use — the runtime has no opinion beyond the protocol.

There is no /metrics endpoint. Metrics are pushed over OTLP like everything else. One telemetry protocol in the binary, and a Collector is what turns it into whatever you actually scrape. Set RUNTIME_OTLP_ENDPOINT and point it at one; leave it unset and everything goes to stdout, which is what you want on a laptop and never in production.

An unreachable collector is not a startup error. A Grafana outage must never become an application outage.

Three log streams, one stdout

Told apart by target, not destination:

TargetWhat
runtimeThe platform. Yours.
runtime::appThe application’s own runtime.log(...). The customer’s.
runtime::auditOne line per row written.

Without that split, a customer’s log("retrying") pages your on-call engineer. Route them differently.

The audit stream

{
  "operation": "delete",
  "entity": "sales.OrderLine",
  "row": "01a0…",
  "subject": "sub-clerk",
  "constrained": false,
  "cascaded": true
}

cascaded tells you the row was reached by a cascading delete rather than asked for, so “what did that delete actually take with it” is answerable afterwards. constrained says whether the caller’s row constraint was in play — which is how you find an entity that is accidentally unconstrained.

Records are emitted after the transaction commits. A rolled-back write never appears, because an audit log that reports things which did not happen is wrong in the direction that makes people stop trusting it.

Traces

Spans carry the route template/api/v1/{module}/{entity}/{id} — never the concrete URL. One time series per route rather than one per entity, which is the difference between working instrumentation and taking your own backend down with it. Unmatched requests are bucketed under a single unmatched label, so a scanner probing random paths cannot mint series by guessing.

Incoming traceparent headers are honoured, so a request continues the caller’s trace instead of starting a new one.

Business metrics

Applications declare their own counters in metrics.yaml, and the runtime maintains them:

sales.orders.written_total{status="Placed"}     2
sales.orders.cancelled_total                    1

These arrive alongside the platform’s own metrics with the names the application author chose. Labels are restricted to enum and boolean attributes — the only types whose value set the model declares — so a model that would explode your backend fails validation rather than producing a bill.

That guard is enforced at model load, not at runtime. You do not have to trust application authors to get cardinality right; the model does not load if they did not.

Correlating the three

The local stack in the deployment repository wires this up as an example: Prometheus for metrics, Tempo for traces, Loki for logs, Grafana over all three, with trace-to-log and log-to-trace links already configured.

The pipeline is OTLP end to end — the runtime pushes to the Collector, the Collector pushes to all three, because Prometheus 3, Tempo and Loki 3 all ingest it natively. Swapping any of them for a vendor’s SaaS is a change to the Collector’s exporters and nothing else. That is the entire point of the runtime speaking only OTLP.

Upgrading

Two things change independently and have different procedures. Confusing them is how a deploy goes wrong.

ChangingProcedureDowntime
The application modelPOST /_admin/model/reloadNone
The database schemaruntime db apply, then a deployNone
The runtime itselfA rolling deployNone

Reloading a model

If a change needs no schema change — a role’s constraint, a flow’s script, a metric, a validation rule — reload it in place:

$ curl -X POST localhost:9090/_admin/model/reload
{"outcome":"reloaded","model":"northwind","version":"0.1.0","flows":1}

It re-reads the bundle the process was started with. It takes no request body, and that is the security design rather than an omission: the admin listener has no authentication, and an endpoint that accepted a model — flow scripts included, which execute — would be remote code execution behind a health port. Re-reading a mounted path bounds the endpoint to whoever already controls that mount.

Every refusal leaves the running model untouched:

ResponseMeaning
200Swapped. The next request uses it.
409The model would change the database schema. Not swapped.
422The bundle does not validate, or a script does not compile. The diagnostics are in the body.

A reload is atomic across the model and its compiled flows: a request that read the new model and the old flows would be running yesterday’s scripts against today’s entities, and there is no window in which that can happen.

Changing the schema

runtime db plan myapp    # what would change
runtime db apply myapp   # do it

db plan is safe against production and is what a reviewer reads before a deploy — it names every change that would take a lock or lose data.

Additive changes are the default. Anything destructive needs --allow-drop, and even then a dropped column is renamed out of the way rather than removed, so the data is recoverable. If the runtime cannot tell a rename from a delete-plus-add it refuses, and no flag overrides that: --allow-drop means “I meant to lose this”, and a refusal means it cannot tell what you meant.

Dropped columns accumulate

--allow-drop renames a column out of the way rather than removing it: fax becomes _dropped__fax__v2, nullable, with the data intact. That is what makes a destructive change recoverable.

Nothing ever reclaims them. That is deliberate — the moment the runtime is willing to delete one, the guarantee is gone — but it does mean somebody eventually drops them by hand, and that somebody is you:

SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE column_name LIKE '\_dropped\_\_%';

They cost a little storage and nothing else. Reclaim them on your own schedule, once you are certain the data is not wanted.

Rolling deploys

They work because migrations are additive, and nothing else makes them survivable: during a roll, two model versions run against one database.

The order that works:

  1. runtime db apply — additive, so the old version keeps working.
  2. Roll the new version out.

A pod serving a model the database has not been provisioned for fails readiness and takes no traffic. That is what makes the deploy wait rather than serve errors, and it is why the model check is in readiness rather than being a startup assertion.

Make sure terminationGracePeriodSeconds is above 35 before you rely on any of this — see Health and shutdown.

What readiness does not notice

It compares the loaded model against the one the database was last provisioned from — not against the live catalogue. So it catches the rolling-deploy case, which is the one that matters, and it does not catch a column somebody dropped by hand with psql.

Command line

One binary, four commands. Every one of them takes its configuration from the environment — see Configuration.

$ runtime --help
Usage: runtime <COMMAND>

Commands:
  serve   Serve an application model
  model   Work with a model bundle: validate it, or emit its JSON Schema
  db      Bring the database in line with the model, or show what that would take
  config  Print the effective configuration and exit

runtime model validate <bundle>

Check a bundle and report everything wrong with it.

$ runtime model validate myapp
northwind — 1 module, 3 entities — ok

Reports every problem, not the first — a model with four mistakes takes one round trip to fix rather than four. Needs no database and no network, so it belongs in a pre-commit hook and in CI.

Exits non-zero if anything is an error. Warnings (M9xx) do not fail it; see Diagnostics.

runtime model schema

Print the JSON Schema for the model format.

runtime model schema > app-model.schema.json

Generated from the same Rust types that parse your model, so it cannot drift from what the runtime actually accepts. Point your editor at it and you get completion and inline errors while you type.

runtime db plan <bundle>

Show what provisioning would change, without changing anything.

$ runtime db plan myapp
plan: app 0.1.0 -> 0.2.0 (schema version 1 -> 2)
  [additive]    add column sales.customer.nickname varchar(40)
  [destructive] soft-drop column sales.customer.fax (renamed to _dropped__fax__v2, data kept)

Safe to run against production, and worth running there. This is what a reviewer reads before a deploy: every change is classified, and the ones that would take a lock or lose data say so.

runtime db apply <bundle>

Bring the database in line with the model.

FlagWhat it does
--allow-dropPermit changes that lose data
--dry-runPrint the plan and stop, as plan does

Everything happens in one transaction — the DDL, the bookkeeping and the advisory lock — so a migration that fails halfway leaves the database exactly as it was rather than in a state no plan describes.

--dry-run exists as well as the plan subcommand so a deployment script can use one command and a flag rather than branching.

--allow-drop does not override a refusal. If the planner cannot tell a rename from a delete-plus-add, it refuses and no flag changes that: the flag means “I meant to lose this”, and a refusal means it cannot tell what you meant. Declare the rename with previousName.

runtime serve <bundle>

Serve an application model. Needs RUNTIME_DATABASE_URL at a minimum.

RUNTIME_DATABASE_URL=postgres://... runtime serve myapp

Loads and validates the bundle, compiles every flow, connects the database and discovers the identity provider — all before binding a port. A syntax error in a script is a failure to start rather than a 500 for whoever calls that flow first.

runtime config

Print the effective configuration and exit.

$ runtime config
Config { app_addr: 0.0.0.0:8080, admin_addr: 127.0.0.1:9090, ... }

Because “which value actually won” is a question asked at the worst possible moment, and answering it should not require attaching a debugger.

Diagnostics

Every message runtime model validate can produce, by code.

Codes are permanent. Retiring a check retires its number with it, and a number is never reused — so a code in a script, a ticket or a runbook means the same thing forever.

A diagnostic names the file, the path within it, what is wrong, and — wherever there is an answer — what to write instead. If one of these is ever unclear, that is worth reporting: the premise of the product is that models are written by people and language models working from the schema and these messages.

$ runtime model validate myapp
error[M203]: `precision` is not a parameter of type `string`
  --> modules/sales/domain.yaml (entities[0].attributes[1])
  help: remove it, or change the type to `decimal`

Parse

The file could not be read as a model at all: it is missing, it is not YAML, or it does not match the schema.

CodeMeaning
M001A file the bundle requires is not there.
M002The file is not well-formed YAML.
M003The YAML is well-formed but does not match the schema.
M004schemaVersion is a version this runtime does not know.

Resolve

The file parsed, and something it names does not exist.

CodeMeaning
M101app.yaml lists a module with no directory.
M102A file’s module: disagrees with the directory it is in.
M103An association targets an entity that does not exist.
M104An index or access rule names an attribute that does not exist.
M105A user role grants a module role that does not exist.
M106roleMapping or anonymousRole names a user role that does not exist.
M107A flow names a script file the bundle does not contain.
M108A flow’s allow names a role this module does not define.
M109A metric’s label names an attribute the entity does not have.

Check

Everything named exists, and something about it does not make sense.

CodeMeaning
M201A name does not follow the casing convention for its kind.
M202Two things in the same scope share a name.
M203A type-specific parameter was given for a type that has no such parameter, such as precision on a string.
M204A type-specific parameter that is required for this type is missing, such as a decimal without precision.
M205A parameter is present but its value cannot be satisfied.
M206A default cannot be represented in the attribute’s type.
M207An index lists no attributes.
M208previousName is the same as name, which declares nothing.
M209Two elements claim the same previousName.
M210A metric label names an attribute whose type is unbounded.
M211A metric name does not follow the OpenTelemetry naming convention.
M212A metric’s when uses something a committed row cannot answer.

Constraints

A constraint or condition expression is wrong.

CodeMeaning
M301A constraint expression does not parse.
M302A path in a constraint does not resolve against the domain model.
M303A comparison puts two incompatible types either side of an operator.

Warnings

Legal, and nearly always unfinished. These do not stop a model from loading.

CodeMeaning
M901An entity with no attributes.
M902A role grants access to an entity in a different module.
M903A flow no role may invoke.
M904A metric no operation triggers.

Severity

M0xx through M3xx are errors: the model does not load.

M9xx are warnings: the model loads and runs. They exist because the thing they describe is legal and almost always a mistake in progress — a flow no role can invoke will never be called, and a metric nothing triggers will read zero forever, which looks exactly like the thing never happening.

The three passes

Which pass reports a problem determines how good the message can be, which is why they are separate:

  1. Parse — is this YAML, and does it match the schema? Nothing is known about the model yet, so the message is about syntax and shape.
  2. Resolve — does sales.Customer exist, does that attribute exist on it? Names can now be checked, and a near-miss gets a suggestion.
  3. Check — is this expression well-typed, is this parameter applicable, does this default fit? The whole model is known, so the message can be specific.

A failure in an early pass stops the later ones for that file, because a message derived from a half-understood model is worse than no message.

Keeping this page honest

The table above is generated from the runtime’s Code enum. Codes are added rarely and never removed, so it drifts slowly — but it does drift. To check:

grep -oE '"M[0-9]{3}"' ../runtime/crates/runtime-model/src/diagnostic.rs \
  | tr -d '"' | sort -u > /tmp/actual
grep -oE 'M[0-9]{3}' src/reference/diagnostics.md | sort -u > /tmp/documented
diff /tmp/actual /tmp/documented

A code in the runtime and not here is a message somebody will meet with nothing to look up.

Glossary

The words this documentation uses, and what they mean here specifically.

Application — a directory of YAML describing a domain, who may see it, and what it does. Not code, and not compiled. Also called a bundle.

Attribute — one field of an entity. Becomes a column.

Association — a relationship between entities. reference becomes a foreign key; referenceSet becomes a junction table.

Bundle — an application, as a directory of files. What serve, validate and db apply all take.

Constraint — an expression on a role’s access rule that decides which rows it can reach. Compiled into the WHERE clause of every query that role issues, never applied afterwards.

Diagnostic — a validation message with a stable code, a location and usually a fix. See Diagnostics.

Entity — one stored thing. Becomes a table.

Flow — a script with a signature: which entity it operates on, which roles may run it, what it takes. Written in Lua. Runs as whoever called it.

Model — the whole application, as the runtime understands it after loading and validating a bundle.

Module — a bounded context within an application: its own entities, roles, flows and metrics. Also the first half of a qualified name, as in sales.Customer.

Module role — a role a module defines in its own terms, carrying the actual permissions. Granted to a user role.

Principal — who is making a request: a subject, an email, a name, and the set of module roles they hold. Derived from a bearer token, or from the model’s anonymous role.

Runtime — the interpreter. One binary, one image, serving any application.

Soft drop — a removed column renamed out of the way and made nullable rather than dropped, so the data is recoverable. What --allow-drop actually does.

System attribute — a field every entity has and nobody declares: id, createdAt, changedAt, createdBy, changedBy, version.

User role — a role your organisation has, declared in app.yaml, which grants one or more module roles. The indirection is what lets a module ship a permission model without knowing anything about the organisation deploying it.

Version — a per-row counter the runtime maintains, carried as an ETag and checked on conditional writes. Distinct from the application version in app.yaml and from the runtime’s own release version.

Release notes

VersionDateNotes
0.1.0The first release. Not yet published.

How versions work

The runtime follows Semantic Versioning. Three things about that are worth stating early, because they are the ones that surprise people.

A bump to the minimum supported Rust version is a minor release, not a major one. It is also the most common reason a downstream build breaks after an upgrade, so it is always called out at the top of a release’s notes rather than left to a table.

The model’s schemaVersion is separate from the runtime’s version. A runtime knows which schema versions it can read; a model declaring one it does not know is refused at load rather than half-understood. A new schemaVersion is a major release.

Diagnostic codes are permanent. A code is never reused and never renumbered, so M203 in a script, a ticket or a runbook means the same thing forever. A retired check takes its number out of circulation with it.

What counts as breaking

For a product whose whole surface is generated from a customer’s model, “public API” needs defining. These are breaking, and only appear in a major release:

  • A change to the authored model format that makes an existing valid model invalid.
  • A change to the HTTP surface that makes an existing correct client wrong — a status code, a response shape, a header a client must send.
  • A schema change the runtime makes to an existing database that is not additive.
  • Removing a diagnostic code, or changing what one means.

These are not breaking, though they may still be worth reading about:

  • A new diagnostic that rejects a model which was previously accepted and wrong. Validation getting stricter about genuine mistakes is the product working.
  • Changes to log lines, span attributes or metric descriptions. Instrument names and metric labels are stable; the prose around them is not.
  • Anything in the runtime’s internal crates. They are published so the binary can be built, not as a library to depend on.

Upgrade order

Always the same, and the reason is that migrations are additive:

  1. runtime db plan — safe against production, and what a reviewer reads.
  2. runtime db apply — the old version keeps working, because the change is additive.
  3. Roll the new runtime out.

A pod serving a model the database has not been provisioned for fails readiness and takes no traffic, which is what makes a rolling deploy wait rather than serve errors. Upgrading has the detail.

0.1.0

Not yet published. There is no tag and no published image. This page describes what 0.1.0 will contain; the date and the pull command go in when it ships.

The first release. One vertical slice, thin in every layer: a model is parsed, validated, provisioned into PostgreSQL, and served as a REST API whose every query — read or write — is filtered by the caller’s role.

What you can build

An application is a directory of YAML. From roughly forty lines you get:

  • A database schema, provisioned from your model and migrated additively.
  • A REST API with cursor pagination, conditional writes and RFC 9457 error documents.
  • An OpenAPI document generated per caller — entities and fields a token cannot reach are absent from it.
  • Access control at three levels — entity, field and row — compiled into every query rather than applied afterwards.
  • Lua flows, sandboxed, for the logic a declarative model cannot express.
  • Business metrics you declare and never instrument.
  • OpenTelemetry for traces, metrics and logs, over OTLP only.

See Your first application for the whole loop.

Worth knowing before you start

These are the decisions most likely to surprise you. Each is deliberate, and each has bitten somebody on a product that decided otherwise.

Money is never a number. decimal crosses every boundary as a string — in JSON and in Lua. A binary float cannot represent 0.10. Sending 12.10 as a JSON number is an error, not a rounding.

Writes require If-Match. PATCH and DELETE without one are refused with 428. Two people editing the same record is not a race condition, it is a Tuesday, and last-write-wins loses one of the changes with no error at all.

There is no PUT. A role that cannot read every field cannot send a complete representation, so the only honest full-replacement semantics would be “and blank everything I cannot see”.

A hidden row and a missing row are both 404. Answering differently would let anyone confirm an identifier by asking for it. The same reasoning makes a field you cannot read report as “not a field of this entity” — identical to a typo.

A flow runs as whoever called it. There is no runAs: and no way to elevate. A flow that reports a total across rows the caller cannot itemise is not expressible.

Metric labels are enum and boolean only. A label with a thousand values is a thousand time series. The model does not load if you try.

Running it

Requires PostgreSQL 18 or later, and an OCI engine to run the image. The runtime is a single stateless binary; run it with a read-only root filesystem, because it writes nothing. The full list of what you have to provide is in Deployment, which is where it stays current — this page describes one version.

Two configuration values are worth setting deliberately before anything else — RUNTIME_DATABASE_MAX_CONNECTIONS, whose real value is this times your replica count, and terminationGracePeriodSeconds above 35, without which pods are killed mid-drain. Deployment covers both.

Known limitations

Worth checking before you build something that needs one:

LimitationDetail
No web application layerAn application is an API only; no pages, forms or layout yet
No sorting, and no total countRows come back by creation time; keyset pagination has no count
No CORSA browser client must be same-origin, behind a proxy
No ?filter=?expand= exists; there is no way to ask for a subset of rows
?expand= is forwards-onlyreference associations only, and no reverse direction
One row per writeNo bulk create or update
referenceSet cannot be writtenJunction tables are created; nothing fills them
Metrics are counters only“Orders placed” works; “revenue booked” does not
A flow operates on one rowNo collection, scheduled or write-triggered flows
Lua onlyThe WebAssembly engine — and so Python — is designed for, not built
Single tenantOne model, one database, per deployment
No rate limitingA body limit and a request timeout exist; nothing bounds volume
No hard CPU bound on a scriptInterpreted loops and memory are bounded; a long C call is not
coroutine is absent from flowsA Lua hook is per-thread, so it would escape the instruction budget
The model is mounted, not pulledThe design is an OCI artifact fetched at start-up; today it is a directory

Upgrading

Nothing to upgrade from. When there is, upgrade notes go here, and anything breaking is called out at the top of the page rather than in a table halfway down.

Getting help

Before asking

Three commands answer most questions faster than a person can:

runtime model validate myapp   # every problem in a bundle, at once
runtime config                 # what configuration actually took effect
curl "localhost:9090/readyz?verbose"   # which dependency is unhappy

Troubleshooting covers what the common refusals mean — and most of what looks like a fault is a refusal with a reason.

Reporting a problem

What makes a report actionable, roughly in order of usefulness:

  1. The diagnostic code, if there is one. M203 is unambiguous; “it says the parameter is wrong” is not.
  2. The smallest bundle that reproduces it. Usually a handful of lines, and producing it often finds the answer.
  3. runtime config, with the database URL redacted.
  4. What you expected instead, which is where most of the value is: a refusal that is correct but surprising is a documentation bug, and worth reporting as loudly as a crash.

Unclear error messages are bugs

Worth saying separately because people do not tend to report these.

The premise of this product is that models are written by people and language models working from the schema and the diagnostics. A message that does not say what to do next is a defect in the thing the product is built on — not a small cosmetic issue. If you had to guess, that is worth a report.

Contributing to these docs

Every page has an “edit this page” link in the top right. The single most effective thing for documentation quality is making the fix take less effort than the complaint.

The site is mdBook; mdbook serve --open gives live reload. src/SUMMARY.md is the navigation, and a page not listed there is not built.