One rule set, in the framework-free @orthacms/content-domain kernel. The
server applies it on every write and is the authority; the admin form applies
the same rules so authors see problems early. Skipping the form does not skip
the rules.
When it runs
| Moment | Non-publishable type | Publishable type |
|---|---|---|
| Save (create / update) | Every rule; required enforced | Only database checks: targets exist, media accept, one-to-one not taken |
| Publish | — | Every rule, against the stored row, plus required many-relations |
A draft may be incomplete or even invalid — too long, pattern mismatch — and still save. See the publish gate.
The error shape
A 422 with every issue found:
curl -X POST https://cms.example.com/api/v1/content/author \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{ "values": { "name": "A", "email": "not-an-email", "handle": "Ada Lovelace" } }'{
"message": "Entry validation failed",
"issues": [
{ "field": "name", "message": "must be at least 2 characters" },
{ "field": "email", "message": "must match pattern ^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$" },
{ "field": "handle", "message": "must match pattern ^[a-z0-9]+(?:-[a-z0-9]+)*$" }
]
}- Messages are sentence fragments — render them after the field label.
- A failed publish has the same shape. Value issues come first; required many-relations are counted only once the values pass.
GraphQL answers HTTP 200 and carries the same facts in the error:
{
"errors": [{
"message": "Entry validation failed",
"path": ["createAuthor"],
"extensions": {
"code": "VALIDATION_FAILED",
"status": 422,
"issues": [{ "field": "name", "message": "must be at least 2 characters" }]
}
}],
"data": null
}Per-type rules and messages
| Type | Checked | Messages |
|---|---|---|
| any | required | is required |
text | length, pattern | must be at least 3 characters, must be at most 200 characters, must match pattern …, must be a string |
richtext | text length, structure | length messages, must be a rich-text document, structural findings |
number | min, max, integer | must be a number, must be a finite number, must be a whole number, must be ≥ 0, must be ≤ 5 |
money | as number, always whole | must be a whole number for 19.99 |
boolean | type | must be true or false |
date | shape and calendar | must be an ISO date (YYYY-MM-DD), must be a real calendar date |
datetime | ISO-8601 with a time | must be an ISO date-time |
select | membership | must be one of: standard, wide, full_bleed |
multiselect | every member | must be a subset of: general, developers |
json | valid JSON | — |
relation | uuid; target visible from this workspace; same locale if localized | must be an entry id, must reference an existing entry, must reference a "de" entry — … |
media | shape, existence, accept, alt text | see below |
Worth knowing:
- Dates exist.
2025-02-30matches the shape and is rejected. - Datetimes carry a time.
2026-10-01is rejected, not read as midnight UTC. - Money is minor units.
1999is 19.99. There is no currency on the field; a three-decimal currency is stored in its own minor unit. - Relation targets may be own records or published records of a shared workspace the workspace reads the type from.
Media messages
| Problem | Message |
|---|---|
Not an id / { id, alt, decorative } | must be a media asset id (multiple: must be an array of media asset ids) |
alt not a string | alt text must be a string |
alt over 1,000 characters | alt text must be at most 1000 characters |
Non-blank alt and decorative: true | cannot be marked decorative and carry alt text |
| Required field, asset without alt or decorative | needs alt text, or to be marked decorative, before it can be published |
| Asset missing or in another workspace | must reference an existing asset |
| Visible asset, wrong kind/type | must be an allowed asset (image) |
Missing and invisible targets — entries or assets — always get the same message, so a caller cannot probe ids it cannot see.
What “empty” means
null, undefined, a whitespace-only string, or []. Empty trips required
or is skipped; it is never coerced.
Text length counts characters
minLength / maxLength count grapheme clusters: an emoji, a decomposed
é, a joined family emoji — each is one.
Unknown field names are dropped, not rejected
A key in values that is not a declared field is ignored: not stored, no
error. That keeps old revision snapshots restorable after a field is removed —
and makes a typo silent.
A typo in a field name is silent — and the admin PATCH replaces
{"values": {"titel": "…"}} returns success and stores nothing. The admin
PATCH /api/content/:type/:id replaces the whole values bag (a declared field
you omit becomes null), so the same typo there also clears title. The
public PATCH /api/v1/..., GraphQL update… and MCP content_update
merge over stored values instead. Read the response back.
Rejected, because there the caller expects something back:
?fields=on the public API — an unknown name is a 400.- The copilot’s write tools.
Regular expressions
pattern goes through a guarded compiler:
| Pattern | Result |
|---|---|
| Does not compile | Fails at boot (define time) |
Prone to catastrophic backtracking (^(a+)+$), or over 1,000 characters | Every non-empty value fails with has an unsafe pattern rule |
So a bad pattern shows up on the first save, not as a hung server.