7 September, 2026
This release tightens up what a failing validation rule tells the caller, lets you compare a relationship directly to null, brings union mappings under keel validate, and teaches the schema formatter to wrap long expressions.
Validation failures no longer report your rule expressions
A @validate rule with no message of its own used to report its expression source text as the error. @validate(order.quantity > 0) came back to the caller as order.quantity > 0, putting a piece of your schema in an API response. A rule with no message now reports a generic validation failed instead:
{
"code": "ERR_INVALID_INPUT",
"message": "one or more validation rules failed",
"data": { "errors": [{ "error": "validation failed" }] }
}Nothing else about the response changed. An action rule is still 400 with ERR_INVALID_INPUT and lists every failure; a model rule is still 409 with ERR_CONFLICT and reports one. A rule that carries a message is unaffected, and the expression is still recorded on the request's trace for whoever is debugging.
So the message is now the only thing a caller has to go on, and it is worth writing one on every rule:
model Order {
fields {
quantity Number
}
@validate(order.quantity > 0, "quantity must be greater than zero")
}Comparing a relationship to null
You can now compare a relationship field itself to null, rather than only its fields:
model Project {
fields {
name Text
team Team
lead Member?
}
actions {
list unledProjects() {
@where(project.lead == null)
}
}
@validate(
project.lead == null || project.lead.team.id == project.team.id,
"the lead must be on the project's team"
)
}This works wherever expressions do: @where, @permission, @computed and both forms of @validate. Written this way before the release, the comparison resolved to a column that does not exist — the relationship's own name rather than its leadId key — so the write, read or permission check failed outright instead of evaluating.
The guard in the example above is what makes an ownership rule work over an optional relationship. Comparing through a relationship that is not set is false rather than null, so project.lead.team.id == project.team.id on its own refuses every project that has no lead at all. Adding project.lead == null || in front lets those rows through and keeps the rule for the projects that do have one. See the @validate reference for how the two forms of the attribute treat null.
Union mappings are checked by keel validate
A union arm's @computed mappings and its @where are now type-checked against the arm's source model when you validate or build, instead of being reported when the migration runs. keel validate catches a mapping that names a field the source model does not have, one that resolves to a type the union field cannot hold, one that traverses a to-many relationship, and one rooted at anything other than the arm's own source model:
field 'doesNotExist' does not exist
expression expected to resolve to type Text but it is Decimal
a union arm cannot traverse a to-many relationshipAn arm's @where is checked the same way, and must resolve to a Boolean. Arm expressions cannot reference ctx, since the arm becomes part of a view definition and there is no request behind it — referencing it is now a build error rather than a migration failure. An arm's expressions are always rooted at the source model's own name, even when the arm carries an as alias.
Worth knowing before you upgrade: a union that was passing keel validate only because its mappings were never checked will now fail validation. That is the mistake surfacing, not a new restriction, but it surfaces at build time where it did not before.
An arm cannot traverse to a to-many relationship, because one source row would then contribute several facts. Summarising one is still fine, since that yields a single value per row: total @computed(SUM(order.lines.amount)) gives exactly one fact per order, however many lines it has.
Long expressions wrap in the formatter
keel format used to leave a long expression on one very long line. It now wraps an expression that would push the line past 80 characters, breaking at operators, function arguments and list elements and indenting the continuation:
list myPosts() {
@where(post.author.id == ctx.identity.id || post.team.members.identity.id == ctx.identity.id || post.published == true)
}becomes:
list myPosts() {
@where(
post.author.id == ctx.identity.id ||
post.team.members.identity.id == ctx.identity.id ||
post.published == true
)
}A long list argument gets one element per line, so a @permission with a row of roles is readable rather than running off the edge:
@permission(
actions: [get, list, create, update, delete],
roles: [
WarehouseOperator,
FinanceController,
SupportAgent,
Auditor,
RegionalManager
]
)Expressions that already fit are left inline. Existing schemas that do carry long expressions will be re-flowed, so expect one large and purely cosmetic diff the first time you run keel format --write after upgrading.
Fixes and Improvements
- Unions: an arm mapping that traverses a to-one relationship of its source model now reaches the related record. The traversal was matched up the wrong way round, which either failed the migration or, worse, built a view that quietly found nothing, so the mapped field came back empty rather than reporting anything. Each mapping is also cast to the field's declared type now, so a field declared
Numberand mapped from aDecimalexpression holds a whole number, and a union's declared types are what its API reports. - Unions: an arm's traversal is joined as its relationship requires rather than always optionally, and a traversal that could turn one source row into several is refused outright instead of quietly multiplying the union's facts.
- Computed fields: a
@computedfield that reads back across the side of a one-to-one relationship that does not hold the foreign key now works. It was resolved as though the key were on its own model, and because that resolution only runs when the field is recomputed, the schema validated and deployed cleanly and then failed every insert and update to the model declaring the field. - CLI:
keel formatno longer drops parts of your schema. An array marker on a job or flow input was deleted, turningnames Text[]intonames Text, and a message field lost its attributes and had its[]and?markers written in the wrong order. Message fields are now printed the same way model fields are.
For any issues or feedback, please contact us at help@keel.so.
Thank you for using Keel!