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

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.