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.