Before-write rules
A rule is a small check that runs on the server before a row is created or updated. If it refuses, nothing is saved. It holds for every write through the app's data API, not just the ones your UI makes.
Why rules
- UI checks can be bypassed. A disabled button stops nobody who calls the data API directly. A rule does.
- Workflows run after the write. A workflow can react to a new row, but it cannot stop it being saved. A rule can.
- Access is about rows, rules are about content. Use
accessandowner_refsfor who may touch which rows. Use a rule for conditions that depend on the data, like "who may send the first message".
Shape
"rules": [
{
"id": "women-first",
"on": "create",
"message": "Women make the first move.",
"check": "<async function body>"
}
]| Key | Meaning |
|---|---|
id | kebab-case, unique within the entity |
on | "create" (default), "update" or "both" |
message | Shown when the check returns false(default "That isn't allowed."; max 200 characters) |
check | The body of an async function over (row, ctx). Up to 4,000 characters. TypeScript syntax is accepted |
The check returns true (or nothing) to allow, falseto refuse with the rule's message, or a string to refuse with that string.
What the check gets
| Name | Value |
|---|---|
row | The row as it would be written. On update: the current row merged with the patch. Server-stamped fields (owner columns, actor_ref) are already set |
ctx.old | The current row on update; null on create |
ctx.user | { id, role } of the writer |
await ctx.me("member") | The writer's own row id in a persona table, or null |
await ctx.db.query(sql, params) | One read-only SELECT (or WITH … SELECT), run as the writer: row-level security applies. Returns { rows } |
ctx.now() | The current time as a Date |
Because queries run as the writer, a rule only sees rows the writer may read. Make sure the access settings let the writer read what the rule needs.
Example: women message first
A dating app with member (with a gender field), match and message. The first message in a match must come from a woman. After that, anyone in the match can reply.
{
"name": "message",
"fields": [
{ "name": "match_id", "type": "ref", "ref": "match" },
{ "name": "sender_id", "type": "ref", "ref": "member" },
{ "name": "body", "type": "text" }
],
"owner_refs": ["match_id"],
"actor_ref": "sender_id",
"rules": [
{
"id": "women-first",
"on": "create",
"message": "Women make the first move — she needs to message first.",
"check": "const prior = await ctx.db.query('select id from message where match_id = $1 limit 1', [row.match_id]); if (prior.rows.length > 0) return true; const me = await ctx.db.query('select gender from member where id = $1', [row.sender_id]); return me.rows[0]?.gender === 'woman';"
}
]
}The same check, formatted:
const prior = await ctx.db.query(
"select id from message where match_id = $1 limit 1",
[row.match_id],
);
if (prior.rows.length > 0) return true; // the conversation has started
const me = await ctx.db.query(
"select gender from member where id = $1",
[row.sender_id], // stamped by actor_ref
);
return me.rows[0]?.gender === "woman"; // false → the rule's messageOn production this rule refused a man's first message sent straight to the API (422, nothing saved), and allowed a woman's first message and his reply after it (201 each).
Adding or changing rules
Put them in the contract and apply, or replace an entity's whole list with a patch op ([] or null removes them):
{ "op": "set_entity", "entity": "message", "rules": [ { "id": "women-first", ... } ] }Limits
- Up to 6 rules per entity. They run in order; the first refusal wins.
- 3 seconds and 64 MB per check.
- Each check runs in a sandbox: a separate process with no environment variables, no file writes, no network and no code generation.
- No writes.
ctx.db.queryaccepts one SELECT only. - Rules run on data-API creates and updates. Workflows (platform code acting for the app) don't run them.
Failure modes
A broken rule fails closed: the write is refused, never allowed.
- Doesn't compile— the contract check reports an error and the contract can't be applied.
- Throws, times out or runs out of memory— the write is refused with the rule's message.
- Returns something other than true, false, nothing or a string — refused.
- Bad id, bad
on, empty check, duplicate id or a seventh rule — dropped, with a line in the report'sfixes.
The response
A refused write answers 422 and saves nothing:
HTTP/1.1 422
{
"ok": false,
"error": {
"code": "RULE_REFUSED",
"message": "Women make the first move — she needs to message first."
}
}Show error.message to the user. A component calling app.create or app.updategets a rejected promise whose error message is the rule's message.
Next
- App contract — entities, access,
actor_ref - Workflows — what happens after a write
- Custom components & branding