Validation rules live in one place — a framework-free kernel — and both runtimes apply them. The server is the authority and re-checks everything on every write. The admin renders the same rules in the form as a courtesy, so an author sees a problem while they can still fix it.
That means a client which skips the form does not skip the rules.
When it runs
- On save, for a non-publishable type: everything is checked, and
requiredis a real constraint. - On save, for a publishable type: present values are checked, but a missing required value is allowed — the draft saves.
- On publish: the full check runs against the stored row. See the publish gate.
What “empty” means
One definition, everywhere: null, undefined, a whitespace-only string, or an
empty array. An empty value either trips required or is skipped; it is never
coerced.
Per-type rules
| Type | Checked for |
|---|---|
text | minLength, maxLength, pattern |
richtext | Text length against minLength/maxLength, plus the structural rules |
number | min, max, and whole-number when integer |
money | min, max, and at most two fractional digits |
boolean | Is a boolean |
date | YYYY-MM-DD, and a date that exists |
datetime | ISO-8601 with a time component |
select | One of the declared options |
multiselect | An array, every member a declared option |
json | Valid JSON |
relation | A uuid, and a target that exists in this workspace |
media | A uuid, an asset that exists, and one accept allows |
Two of these are worth calling out.
Dates are checked against the calendar, not just the shape. 2026-13-45 and
2025-02-30 both match YYYY-MM-DD and neither exists, so both are rejected
here rather than becoming a hard error — or a silently rolled-over date —
wherever they are eventually parsed.
Datetimes must carry a time. A date-only string is rejected rather than
being coerced to UTC midnight, which is what Date.parse would have done.
Money allows at most two fractional digits. The field spec has no currency member, so the kernel enforces the near-universal two minor units rather than guessing. A three-decimal currency — BHD, KWD, TND — needs a currency-aware rule that does not exist yet.
Text length is counted in characters
minLength and maxLength count user-perceived characters via grapheme
segmentation, not UTF-16 code units. An emoji costs one, a decomposed é costs
one, and a family emoji made of several joined code points costs one — which is
what a “at most 200 characters” message means to the person typing.
The error shape
A failure is a 422 carrying a list of issues, each naming the field and what was wrong:
{
"statusCode": 422,
"message": [
{ "field": "title", "message": "must be at most 200 characters" },
{ "field": "author", "message": "must reference an existing author" }
]
}Messages read as sentence fragments so they render correctly after the field’s label.
Relation and media failures are deliberately uniform: a target that does not
exist, one in another workspace, and one that accept disallows all produce the
same message. Distinguishing them would let a caller confirm whether an id they
cannot otherwise see is real.
Unknown field names are dropped, not rejected
This is the one asymmetry worth knowing about, because it is easy to misread.
A key in a write payload that is not a declared field is silently ignored. It is not stored and it is not an error.
That is deliberate in one direction: a stored revision snapshot that outlived a removed field is still restorable, because the extra keys just fall away. It is a rough edge in the other: a misspelt field name in a hand-written request writes nothing and says nothing.
A typo in a field name is silent
POST /api/content/article with {"values": {"titel": "…"}} returns 200 and
stores nothing for that field. If you are writing against the API by hand, read
the response back and check that what you sent is what came home.
Two surfaces do reject unknown names, because there the caller is naming
something they expect back: the public API’s ?fields= parameter, and the
copilot’s write tools — where the cost is a person approving a change they
believe writes a field that does not exist.
Regular expressions
A pattern is compiled through a guarded compiler rather than handed straight
to the regex engine, so a pathological pattern in a content type cannot become a
denial of service on every write.
Rich-text structure
A rich-text body additionally goes through the structural rules — heading order, table headers, link text, language tags. Errors block the save; the single warning does not. See rich text.