Validation rules and unique constraints
Field constraints, formula-based validation rules, immutable fields, single-column and composite unique constraints, and the errors they raise.
Every write — console, API, CLI, MCP, flow — goes through the same validation pipeline: required and type checks,
field constraints, validation rules, immutability, uniqueness and referential integrity. A field-level failure
answers 400 with one entry per problem: title is Validation Error, source.pointer names the field
(/data/attributes/<field>), and code is the constraint that failed (nullable, type, minLength,
maxLength, minValue, maxValue, pattern, enum, picklist, immutable, unique, reference).
Field constraints#
The constraints JSON on a field declares simple checks. These keys are enforced on every create and on every
update that writes the field:
| Key | Applies to | Check |
|---|---|---|
min, max | numbers, CURRENCY, PERCENT | value bounds (same as minValue, maxValue) |
min, max | STRING, TEXT, RICH_TEXT, EMAIL, URL, PHONE, EXTERNAL_ID | length bounds (same as minLength, maxLength) |
minValue, maxValue | numeric values | value bounds |
minLength, maxLength | text values | length bounds |
pattern | text values | the whole value must match the regular expression |
An explicit minLength/maxLength/minValue/maxValue wins over min/max.
Not enforced: email and url (the console’s checkboxes for a text field — use the EMAIL or URL field type,
whose format is checked, or a pattern), and min/max on DATE/DATETIME. The worker ignores these keys and
logs a warning when it loads the field.
Picklist membership: a PICKLIST value must be one of the field’s active picklist values (its own, or
those of the global picklist it is bound to); each element of a MULTI_PICKLIST value likewise. Deactivating a
value stops new writes from using it. A picklist field with no values accepts any text.
required is checked here too.
Updates only check what they write. A PATCH validates the fields in its body, so a record that already
holds a value the constraints now reject (written before they were enforced, or before a value was deactivated)
can still be updated as long as the update leaves that field alone.
Validation rules#
A validation rule is a formula that describes the error. When it evaluates to TRUE, the record is rejected
with the rule’s message. (This is the opposite of “the record is valid when…” — a common mistake when writing rules
by hand or from an agent.)
kelta validation-rules create invoices \
--name "Due date after issue date" \
--formula "due_date < issue_date" \
--message "The due date cannot be before the issue date" \
--error-field due_date
| Attribute | Meaning |
|---|---|
errorConditionFormula | The formula. Syntax and functions: Formula fields. |
errorMessage | Shown to the user and returned in detail. |
errorField | Attaches the error to a field (source.pointer: /data/attributes/<field>); otherwise it is record-level. |
evaluateOn | CREATE, UPDATE or CREATE_AND_UPDATE (default). |
severity | ERROR blocks the write; WARNING is surfaced to the UI but does not block. |
enforceOnClient | Evaluate in the browser before submitting for immediate feedback (the server always evaluates). |
active | Inactive rules are skipped. |
Rules are managed under a collection’s Validation rules tab, with kelta validation-rules, or MCP
create_validation_rule / update_validation_rule / delete_validation_rule.
Rules over related records#
A formula sees only the record being written, but a roll-up summary field
brings child records into it. When an active rule names a ROLLUP_SUMMARY field of its collection, the platform
computes that roll-up from the child rows at write time — one aggregate query per roll-up, however many children
there are — and the rule sees the result. A value for the roll-up sent in the request body is ignored, so a client
cannot bypass the rule by supplying its own count. Rules that name no roll-up field cost nothing extra.
Example: a project cannot close while it has open tasks. tasks has a master-detail field project pointing
at projects and a status field. Add a filtered COUNT roll-up to projects:
{
"name": "openTasks",
"type": "ROLLUP_SUMMARY",
"fieldTypeConfig": {
"childCollection": "tasks",
"foreignKeyField": "project",
"aggregateFunction": "COUNT",
"filter": { "status": "open" }
}
}
Then the rule:
kelta validation-rules create projects \
--name "No close with open tasks" \
--formula 'AND(state = "closed", openTasks > 0)' \
--message "Close or cancel the open tasks first" \
--error-field state
Setting a project’s state to closed while one of its tasks has status open answers 400 VALIDATION_FAILED
with that message and source.pointer: /data/attributes/state. With no tasks, or only tasks in other statuses, the
update goes through. The roll-up filter is an equality match on child fields; use SUM, MIN, MAX or AVG
the same way (for example “an order’s line total cannot exceed its credit limit”).
The roll-up is read when the parent is written, not locked: a task created at the same moment the project closes is not seen by that check.
Immutable fields#
A field can be declared immutable. An update that would change it fails loudly with VALIDATION_FAILED
(constraint: "immutable") rather than silently ignoring the change. Re-sending the value a record already holds
is accepted, so read → edit one field → write the whole record still works.
Unique constraints#
Single column. Set uniqueConstraint: true on the field (--unique in the CLI). EXTERNAL_ID fields are
always unique.
Composite. Declare a constraint over several fields:
POST /api/admin/collections/invoices/unique-constraints
{ "fieldNames": ["customer", "number"] }
GET lists them; DELETE /api/admin/collections/{name}/unique-constraints/{indexName} drops one. CLI:
kelta constraints list|create|delete; MCP: create_unique_constraint, list_unique_constraints,
delete_unique_constraint.
A violation answers 409 Conflict naming the constraint.
Referential integrity#
A lookup or master-detail value must resolve to an existing record in the target collection; otherwise the write
fails with code reference and meta.field, meta.targetCollection and meta.value.
Error shape#
{
"errors": [
{
"status": "400",
"code": "VALIDATION_FAILED",
"title": "Validation Error",
"detail": "The due date cannot be before the issue date",
"source": { "pointer": "/data/attributes/due_date" },
"meta": { "requestId": "abc12345" }
}
]
}
Branch on code, never on detail. See Error responses.