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 are | Start here |
|---|---|
| New to this | Getting started |
| Building an application | Runtime |
| Running the platform | Deployment |
| Looking for what changed | Releases |
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 — build one from scratch.
- Runtime — the reference for each part of a model.
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 thesecurity.yamlwith 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
flattenanywhere 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:andschemaVersion:. 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 configured — RUNTIME_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`
| Check | Failing means |
|---|---|
database | The pool cannot reach PostgreSQL, or the query timed out |
model | The database was provisioned from a different model |
draining | A 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.
428 — PATCH 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
outcome | Meaning |
|---|---|
schema-changed | The model needs a migration. Run runtime db apply and deploy. |
invalid | The 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.
| File | Reference |
|---|---|
domain.yaml | Modelling a domain |
security.yaml | Security |
flows.yaml | Flows |
metrics.yaml | Metrics |
| — | 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
| Thing | Casing | Example |
|---|---|---|
| modules | lower_snake_case | sales |
| entities | UpperCamelCase | Customer |
| attributes | lowerCamelCase | companyName |
| associations | UpperCamelCase | Customer |
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_case — companyName is the company_name column,
and that is the name you see in API responses too.
Types
| Type | Parameters | Notes |
|---|---|---|
string | maxLength | Bounded text |
text | — | Unbounded |
integer | min, max | 32-bit |
long | min, max | 64-bit |
decimal | precision, scale | The only correct type for money |
boolean | — | |
datetime | — | RFC 3339, stored as timestamptz |
date | — | YYYY-MM-DD |
enum | values | The 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.
referenceSetcannot 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 areferenceto each side, which also gives you somewhere to put the attributes such a relationship usually turns out to need.
onDelete is the interesting field:
| Value | What happens when the target is deleted |
|---|---|
restrict | The delete is refused while references remain. Default. |
cascade | This row goes too. |
setNull | The 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:
| Level | The question it answers |
|---|---|
| Entity | May this role read/create/write/delete this at all? |
| Attribute | May it see or change this particular field? |
| Row | Which 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
| Situation | Answer |
|---|---|
| No role grants the entity at all | 403 |
| A row the constraint excludes | 404 — same as a row that is gone |
| A field the role cannot read | Absent from the response |
| Writing a field it may read but not write | 422, naming the field |
| Writing a field it cannot read | 422, “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.
| Status | When |
|---|---|
401 | No token, or one that did not validate |
403 | Authenticated, not permitted |
404 | No such row — or one you may not see. Deliberately the same |
409 | A stale If-Match, or something is in the way |
422 | Understood, cannot be done. Field problems are here |
428 | A 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
| Global | What it is |
|---|---|
row | The entity row, already filtered by the caller’s policy |
params | The declared parameters, already validated |
user | subject, email, name, roles |
runtime | The 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
pcalldoes 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
| Field | What |
|---|---|
name | Lower case, dot-separated. Namespace it yourself |
entity | Whose commits it counts |
on | create, write, delete. Empty counts nothing |
when | Optional condition on the committed row |
labels | Attributes 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 provide | Version | Without it |
|---|---|---|
| PostgreSQL | 18 or later | The runtime will not start. There is no other database option. |
| An OCI engine | any | You build from source instead; the image is the supported path. |
| An OIDC provider | any, by discovery | Every request is anonymous and every bearer token is rejected. |
| An OTLP endpoint | any collector | No 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
| Port | What | Expose it? |
|---|---|---|
8080 | End-user traffic. Authenticated. | Yes |
9090 | Health, 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, and the two that matter.
- Securing a deployment — what is yours to handle.
- Health and shutdown — probes, and the drain.
- Observability — what comes out.
- Upgrading — models, schemas, and rolling deploys.
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
| Variable | Default | Notes |
|---|---|---|
RUNTIME_DATABASE_URL | — | Required to serve |
RUNTIME_APP_ADDR | 0.0.0.0:8080 | End-user traffic |
RUNTIME_ADMIN_ADDR | 127.0.0.1:9090 | Widen in a container; never route to it |
RUNTIME_DATABASE_MAX_CONNECTIONS | 10 | See below |
RUNTIME_DRAIN_DELAY | 5 | Seconds. See below |
RUNTIME_OIDC_ISSUER | — | Both or neither |
RUNTIME_OIDC_AUDIENCE | — | Both or neither |
RUNTIME_OTLP_ENDPOINT | — | Unset exports to stdout |
RUNTIME_ENVIRONMENT | development | Tags every span, metric and log |
RUNTIME_LOG | info | tracing filter directives |
RUNTIME_LOG_FORMAT | json | Or 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/suball 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
NetworkPolicythat 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 configprints 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
| Endpoint | Asks | Where |
|---|---|---|
/livez | Should I be killed and restarted? | Both ports |
/readyz | Should traffic be routed to me? | Admin only |
/healthz | Everything, for a human | Admin 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:
- The signal arrives. Readiness starts failing immediately — that is what tells the endpoints controller to stop routing here.
- 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. - It stops accepting and finishes what is in flight.
- 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:
| Target | What |
|---|---|
runtime | The platform. Yours. |
runtime::app | The application’s own runtime.log(...). The customer’s. |
runtime::audit | One 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.
| Changing | Procedure | Downtime |
|---|---|---|
| The application model | POST /_admin/model/reload | None |
| The database schema | runtime db apply, then a deploy | None |
| The runtime itself | A rolling deploy | None |
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:
| Response | Meaning |
|---|---|
200 | Swapped. The next request uses it. |
409 | The model would change the database schema. Not swapped. |
422 | The 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:
runtime db apply— additive, so the old version keeps working.- 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.
| Flag | What it does |
|---|---|
--allow-drop | Permit changes that lose data |
--dry-run | Print 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.
| Code | Meaning |
|---|---|
M001 | A file the bundle requires is not there. |
M002 | The file is not well-formed YAML. |
M003 | The YAML is well-formed but does not match the schema. |
M004 | schemaVersion is a version this runtime does not know. |
Resolve
The file parsed, and something it names does not exist.
| Code | Meaning |
|---|---|
M101 | app.yaml lists a module with no directory. |
M102 | A file’s module: disagrees with the directory it is in. |
M103 | An association targets an entity that does not exist. |
M104 | An index or access rule names an attribute that does not exist. |
M105 | A user role grants a module role that does not exist. |
M106 | roleMapping or anonymousRole names a user role that does not exist. |
M107 | A flow names a script file the bundle does not contain. |
M108 | A flow’s allow names a role this module does not define. |
M109 | A metric’s label names an attribute the entity does not have. |
Check
Everything named exists, and something about it does not make sense.
| Code | Meaning |
|---|---|
M201 | A name does not follow the casing convention for its kind. |
M202 | Two things in the same scope share a name. |
M203 | A type-specific parameter was given for a type that has no such parameter, such as precision on a string. |
M204 | A type-specific parameter that is required for this type is missing, such as a decimal without precision. |
M205 | A parameter is present but its value cannot be satisfied. |
M206 | A default cannot be represented in the attribute’s type. |
M207 | An index lists no attributes. |
M208 | previousName is the same as name, which declares nothing. |
M209 | Two elements claim the same previousName. |
M210 | A metric label names an attribute whose type is unbounded. |
M211 | A metric name does not follow the OpenTelemetry naming convention. |
M212 | A metric’s when uses something a committed row cannot answer. |
Constraints
A constraint or condition expression is wrong.
| Code | Meaning |
|---|---|
M301 | A constraint expression does not parse. |
M302 | A path in a constraint does not resolve against the domain model. |
M303 | A comparison puts two incompatible types either side of an operator. |
Warnings
Legal, and nearly always unfinished. These do not stop a model from loading.
| Code | Meaning |
|---|---|
M901 | An entity with no attributes. |
M902 | A role grants access to an entity in a different module. |
M903 | A flow no role may invoke. |
M904 | A 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:
- 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.
- Resolve — does
sales.Customerexist, does that attribute exist on it? Names can now be checked, and a near-miss gets a suggestion. - 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
| Version | Date | Notes |
|---|---|---|
| 0.1.0 | — | The 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:
runtime db plan— safe against production, and what a reviewer reads.runtime db apply— the old version keeps working, because the change is additive.- 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:
| Limitation | Detail |
|---|---|
| No web application layer | An application is an API only; no pages, forms or layout yet |
| No sorting, and no total count | Rows come back by creation time; keyset pagination has no count |
| No CORS | A 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-only | reference associations only, and no reverse direction |
| One row per write | No bulk create or update |
referenceSet cannot be written | Junction tables are created; nothing fills them |
| Metrics are counters only | “Orders placed” works; “revenue booked” does not |
| A flow operates on one row | No collection, scheduled or write-triggered flows |
| Lua only | The WebAssembly engine — and so Python — is designed for, not built |
| Single tenant | One model, one database, per deployment |
| No rate limiting | A body limit and a request timeout exist; nothing bounds volume |
| No hard CPU bound on a script | Interpreted loops and memory are bounded; a long C call is not |
coroutine is absent from flows | A Lua hook is per-thread, so it would escape the instruction budget |
| The model is mounted, not pulled | The 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:
- The diagnostic code, if there is one.
M203is unambiguous; “it says the parameter is wrong” is not. - The smallest bundle that reproduces it. Usually a handful of lines, and producing it often finds the answer.
runtime config, with the database URL redacted.- 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.