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:

KeyApplies toCheck
min, maxnumbers, CURRENCY, PERCENTvalue bounds (same as minValue, maxValue)
min, maxSTRING, TEXT, RICH_TEXT, EMAIL, URL, PHONE, EXTERNAL_IDlength bounds (same as minLength, maxLength)
minValue, maxValuenumeric valuesvalue bounds
minLength, maxLengthtext valueslength bounds
patterntext valuesthe 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
AttributeMeaning
errorConditionFormulaThe formula. Syntax and functions: Formula fields.
errorMessageShown to the user and returned in detail.
errorFieldAttaches the error to a field (source.pointer: /data/attributes/<field>); otherwise it is record-level.
evaluateOnCREATE, UPDATE or CREATE_AND_UPDATE (default).
severityERROR blocks the write; WARNING is surfaced to the UI but does not block.
enforceOnClientEvaluate in the browser before submitting for immediate feedback (the server always evaluates).
activeInactive 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.

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.

Edit this page on GitHub