17 August, 2026
This release makes the @validate attribute enforce your business rules at runtime, and fixes cursor pagination on searchable list actions.
Validation rules on write actions
@validate lets you express a business rule that must hold for a write to proceed, directly in the schema. Rules like "an order can only be dispatched once it is packed" no longer have to live in a function hook or a subscriber.
model Order {
fields {
customer Customer
quantity Number
status OrderStatus
trackingNo Number?
}
actions {
create createOrder() with (customer.id, quantity) {
@validate(quantity > 0, "quantity must be greater than zero")
}
update dispatch(id) with (trackingNo) {
@set(order.status = OrderStatus.Dispatched)
@validate(order.status == OrderStatus.Packed, "order must be packed")
@validate(order.customer.isActive, "customer account is not active")
}
delete deleteOrder(id) {
@validate(order.status != OrderStatus.Dispatched, "dispatched orders cannot be deleted")
}
}
}The first argument is a boolean expression and the second is an optional error message. You can put more than one @validate on an action, in which case all of them must hold, and a failing request reports every rule that failed rather than stopping at the first:
{
"code": "ERR_INVALID_INPUT",
"message": "one or more validation rules failed",
"data": {
"errors": [{ "error": "order must be packed" }]
}
}Rules can read the action's inputs and ctx. On update and delete they can also read the record and traverse its relations, and they see the record as it was before the write lands, so order.status above is the status the order had when the request arrived even though the same action sets it to Dispatched. That is what makes the rule a guard on the transition. On create there is no record to read yet, so rules reference the inputs directly.
@validate is only valid on built-in create, update and delete actions. It is not accepted on get or list, where @where is the way to filter what a read returns, nor on @function actions, which implement their own behaviour. Permission checks always run first, so a caller who is not allowed to perform the action gets ERR_PERMISSION_DENIED and no rule is evaluated.
See the @validate reference for the full details.
Breaking change: existing @validate rules now take effect
@validate has been part of the schema language for a long time but was never enforced. Any rule already written in your schema was parsed and then ignored. It now runs, so a write that your schema always said was invalid will start failing with a 400 where it previously succeeded. Before upgrading, search your schema for @validate and check that each rule says what you want it to say today.
Version 0.472 added the schema-level checks for @validate, so usages that were previously accepted without complaint can now fail keel validate at build time. This mostly affects rules placed on action types that do not support them, and expressions that do not resolve to a boolean.
The sharpest edge is null handling. Rules are evaluated by the database and follow Postgres three-valued logic, which means a comparison against null is not true and the rule fails. This catches optional inputs the caller omitted, nullable fields that are not set, and absent relations:
// Rejects a request that omits trackingNo, because null > 0 is not true.
@validate(trackingNo > 0)To write a rule that only applies when the value is present, guard it with IF:
@validate(IF(trackingNo > 0, trackingNo > 10, true), "tracking number must be above ten")Finally, @validate guards the action boundary. Writes made through the Model API from a custom function, subscriber, flow or task, raw SQL through useDatabase, and seed data all bypass these rules, so treat them as guards on a request rather than invariants the stored data can never break.
Fixes and Improvements
- List actions: Paging past the first page of a
@searchablelist action now returns the next page instead of failing withERR_INTERNAL. Actions that combine@searchablewith a schema@orderByare fixed too.
For any issues or feedback, please contact us at help@keel.so.
Thank you for using Keel!