0.474 (19 Aug 2026)

19 August, 2026

This release lets you declare @validate rules on a model, so the database enforces them for every writer rather than only for the action that declares them.

Validation rules on your models

@validate(<boolean expression>), with an optional second argument for the error message, now goes on a model block as well as on an action. On a model it expresses an invariant the record must always satisfy, and it sits alongside @permission and @unique:

model Order {
    fields {
        quantity Number
        customer Customer
        items Item[]
    }
 
    @validate(order.quantity > 0, "quantity must be greater than zero")
    @validate(order.customer.isActive, "the customer must be active")
    @validate(SUM(order.items.amount) <= 1000, "the order total is too large")
}

The migration compiles each rule into Postgres triggers, so the rule holds for every writer: auto-implemented actions, a function writing through Kysely or raw SQL with useDatabase, the Model API a subscriber or flow uses, and seed files. The action-level rules added in 0.473 are different: they only bind the action that declares them.

A model rule can read the record, its relations, its computed fields, aggregates over a to-many, quantifiers and date functions. It cannot read ctx, so it has no access to the request, the calling identity, secrets or environment variables. Referencing ctx is a schema error that points you at the action-level form. A field must be reached through the record, so write order.quantity rather than quantity.

A caller whose write breaks a rule gets 409 with ERR_CONFLICT, carrying the rule's message:

{
  "code": "ERR_CONFLICT",
  "message": "a validation rule failed",
  "data": { "errors": [{ "error": "quantity must be greater than zero" }] }
}

ERR_CONFLICT rather than the action-level 400 and ERR_INVALID_INPUT, because the input was well formed and it is the record's state that conflicts. A rule with no message reports its own expression source. Exactly one rule is ever reported, because the first failure aborts the statement, whereas an action's rules are evaluated together and report every failure.

Writes to a relation are constrained too, because a rule reading a relation is re-checked when that relation changes. So SUM(order.items.amount) <= 1000 refuses an item insert that would take the order over its cap, and order.customer.isActive refuses deactivating a customer who still holds orders. The write that gets refused is the one to the related row, and the rule that refuses it belongs to the record.

The unit of evaluation is the statement, not the row. A multi-row INSERT with one violating row is refused whole, and a bulk UPDATE that leaves any row violating leaves every row unchanged. That is what lets a nested create satisfy an aggregate rule, since the rule sees the parent and its children together.

Null is handled the opposite way round from an action rule, which is worth checking whenever you move a rule between the two. An action rule that evaluates to null fails: @validate(trackingNo > 0) on an action rejects a request that simply omits trackingNo. The same expression as a model rule passes, because only a rule the database can judge to be false counts as a violation, and null > 0 is null. That also covers a rule reaching through a relation that is absent, and a quantifier over no rows. To have a model rule reject a record whose value is missing, test for it: order.trackingNo != null && order.trackingNo > 0.

Rules have to hold at every statement boundary, so a presence-style rule such as COUNT(order.items) > 0 holds for a nested create, where the parent and its children are written by one statement, but not for a writer that creates the parent in one statement and its children in another. Rules are also not enforced under session_replication_role = replica, which disables triggers.

See the @validate reference for the full details.

Deploying a rule against existing data

Adding a model rule to a model that already holds data is the part to plan for. The migration checks the rows already in the table and refuses to deploy if any of them violate the new rule:

cannot enforce @validate on Order: 2 existing row(s) violate it
DETAIL:  rule: quantity must be greater than zero
HINT:  correct or remove the offending rows, then deploy again

Correct or remove those rows and deploy again. A rule that deployed is a rule that actually holds.

A write made from a custom function reports the same failure. The rule's message travels back through the functions runtime, so a useDatabase write that breaks a rule surfaces as the 409 above rather than as a generic internal error. See Error handling.

Fixes and Improvements

  • Migrations: Dropping a field that a composite @unique covers no longer wedges the deploy. Postgres cascades the covering constraint away along with the column, and the planner used to also plan an explicit drop of that constraint, which aborted the batch with an undefined-object error. Because the batch is one transaction, the abort rolled the cascade back too, so every retry failed identically. The planner now accounts for the cascade, and re-adds a cascaded-away constraint that your schema still declares.
  • Expressions: IF(...) now accepts branches whose types differ but are compatible, so IF(product.isActive, product.price, 0) validates when price is a Decimal. The result takes the type of the then branch. Mixing families, such as a Decimal with a Text, is still rejected.
  • CLI: keel run no longer reloads in a loop on a project with an integration module. Generated *.gen.ts module files are rewritten on every build, and the file watcher was treating them as your own edits.

For any issues or feedback, please contact us at help@keel.so.

Thank you for using Keel!