--- name: alvo-descriptor-hooks description: Use when an Alvo descriptor change must refuse a write or fill in a value as a row is written — before-hooks with a condition and a reject or mutate action. --- # Before-hooks in an Alvo descriptor An entity's `hooks` hold lists under `beforeCreate`, `beforeUpdate` and `beforeDelete`. Each entry is an optional `condition` and one `action`: - `{"reject": "…"}` cancels the write; the text is the error the caller reads, so write it for them. - `{"mutate": {"": …}}` sets fields before the write: each value is a JSON literal or `{"$cel": "…"}`. A before-hook runs inside the write's transaction, with no network: it can refuse or change this row, and nothing else. A hook without a `condition` runs on every write of its kind, and never on a schedule: a timed job is `automation`, which this build does not run. The shape: `schema/project.schema.json#/$defs/beforeHookList`. A `condition` reads the row as it will be with `new.` on create and update, and as it was with `old.` on update and delete; `changed()`, true when an update changes the field, is for update only. The other combinations are refused at apply: a delete has no `new.`, a create no `old.`. - allowed: `new.quantity <= 0.0` `new.quantity * new.unit_price > 1000.0` `old.unit_price != new.unit_price` `changed(unit_price)` `'technician' in @user.roles` `size(new.description) > 3` `lowerAscii(new.description) == 'brake pads'` `startsWith(new.description, 'Brake')` - refused: `quantity * unit_price` `now()` Text tests (`startsWith`, `endsWith`, `contains`) are case-sensitive: compare `lowerAscii(new.)` to ignore case. A `mutate` value is a field, a literal, arithmetic (`+ - * /`, unary `-`), `+` joining two strings, or a call to one of these built-in functions (all may nest), and nothing more: no `@user` or `@tenant`. A number joins through `string()`: `'#' + string(new.quantity)`. Every function takes any value of the right type, and a null argument or operand makes the value null. An Int divided by an Int stays an Int, cut toward zero; an overflow or a division by zero refuses the write, in a `mutate` and in a `condition` alike. `contains` `endsWith` `int` `lowerAscii` `math.abs` `math.ceil` `math.floor` `math.greatest` `math.least` `math.round` `now` `replace` `size` `startsWith` `string` `substring` `timestamp` `trim` `upperAscii` A `substring` past the end fails the write, so to fit a field's `maxLength`, cut: `substring(new.description, 0, math.least(size(new.description), 40))`. A value derived from other fields of the row belongs in a computed field, which stays true on every write; a `mutate` stamps it once. An embedded host may register its own CEL functions, which compute a value and never run your code (the `function` action is refused); they work in a `condition` and a `mutate` and nowhere else. Call `get_cel_functions` for this host's list with each function's parameters and result — never assume one exists. A function whose meaning changes gets a new name (`vatRate` stays, `vatRate2` is new). Alvo's tenant filter does not reach inside a function: one that reads stored data must take the tenant as a parameter and filter by it. - allowed: `now()` `lowerAscii(new.description)` `new.unit_price` `'part'` `trim(new.description)` `quantity * 2.0` `upperAscii(trim(new.description))` `math.round(new.unit_price * 1.2, 2)` `'#' + upperAscii(new.description)` `startsWith(new.description, 'Brake')` - refused: `new.quantity > 0.0` `'admin' in @user.roles` `changed(quantity)` A `mutate` value has no comparison operator (a text test such as `startsWith` is a call, and may fill a boolean field). For a flag decided by a comparison, let the `condition` compare and the `mutate` write the literal, with a second hook for the opposite case. Adding a hook depends on what the entity already has: - The slot exists: `add` at `/entities//hooks//-`. - `hooks` exists without that slot: `add` at `/entities//hooks/` with a list holding the hook. An append to a list that does not exist is refused. - No `hooks` at all: `add` at `/entities//hooks` holding the whole object. **A new order line may not have a zero unit price.** ```json {"tool": "propose_change", "baseRevision": 1, "summary": "Refuses a new order line with a zero unit price.", "operations": [{"op": "add", "path": "/entities/order_lines/hooks/beforeCreate/-", "value": {"condition": "new.unit_price == 0.0", "action": {"reject": "An order line needs a unit price. Enter the price charged for it."}}}]} ``` ```json {"valid": true, "changedPaths": ["/entities/order_lines/hooks/beforeCreate/1"]} ``` **A new service order may not book negative labour hours** — `service_orders` has `hooks`, but no `beforeCreate`. ```json {"tool": "propose_change", "baseRevision": 1, "summary": "Refuses a new service order with negative labour hours.", "operations": [{"op": "add", "path": "/entities/service_orders/hooks/beforeCreate", "value": [{"condition": "new.labour_hours < 0.0", "action": {"reject": "Labour hours cannot be negative. Enter the hours worked, or 0."}}]}]} ``` ```json {"valid": true, "changedPaths": ["/entities/service_orders/hooks/beforeCreate"]} ``` After-hooks (`afterCreate`, `afterUpdate`, `afterDelete`) run after the commit, and only some of their action types run in this build: see `alvo-descriptor-capabilities-and-limits`. In the dashboard: read with `get_descriptor`, then `check_change` or `propose_change` the operations. In this repo: edit `examples/**/*.alvo.json` or your own descriptor, then run `scripts/test-ring0` or `PUT …/descriptor?dryRun=true`.