Schema
Rules the API can't skip
A validation rule that lives in a form protects the form. Attach it to the column instead and every writer gets the same answer — the REST API, the grid, the import, the Zap, the agent.
Put a validation rule in a form and you have protected the form. You have not protected the table. The REST API does not have that rule. Neither does the CSV import, the nightly Zap, or the agent holding an API key.
So in SchemaStack a constraint is not attached to a form. It is attached to the column, and the generated REST API enforces it on the way in. Every writer gets the same answer because there is only one place the answer comes from.
What you get without asking
Three checks are always on, derived from the schema itself rather than configured:
- Non-nullable columns must be present when you create a row.
- String length cannot exceed the column's declared
length. - Types must be compatible — a word in a filter on an
INTEGERcolumn is refused rather than coerced into something surprising.
None of that needs a rule written. It falls out of the column definition you already made.
The seventeen
On top of that, seventeen constraint types you attach explicitly: NOT_BLANK, MIN_LENGTH, MAX_LENGTH, PATTERN, EMAIL, URL for strings; MIN, MAX, POSITIVE, NEGATIVE, POSITIVE_OR_ZERO, NEGATIVE_OR_ZERO for numbers; PAST, FUTURE, PAST_OR_PRESENT, FUTURE_OR_PRESENT for dates and times.
The seventeenth is the interesting one. REQUIRED means a value must be present on every write — while the column itself can stay nullable in the database. That separation matters more than it sounds: it lets you require something of everyone writing through the API today without rewriting a table that already has a million rows with nulls in it. The rule binds to the contract, not to the storage.
Each constraint can carry its own message. Without one you get a sensible default — must match pattern: ^[A-Z]{3}-\d{4}$ — which is fine for a developer and useless for anybody else, so a real message is usually worth the ten seconds.
Five of the seventeen only became true recently
This post claimed all seventeen were enforced on every path. For two days that was not accurate, and the gap was exactly where it would hurt most. The generated REST API implemented twelve of them; REQUIRED and the four date rules fell through to a default that let the value pass. So the very rule described above as binding to the contract rather than the storage — the one whose point is to demand a value while the column stays nullable — was enforced when a person typed in the grid and skipped when a program wrote through the API.
That is the precise failure this post was written to argue against, sitting inside the post. It is closed now: all seventeen are enforced by the generated API, with thirteen new tests holding them there. We would rather correct it in place than quietly drop a sentence.
What a failure looks like
A rejected write is a 422 with one entry per broken rule, named by field:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed: 3 error(s)",
"details": [
{ "field": "name", "message": "name is required" },
{ "field": "price", "message": "must be positive" },
{ "field": "sku", "message": "Product code must follow the format ABC-1234" }
]
}
}Not the first error — all of them. A form that has to round-trip once per mistake is a form people abandon.
Create and update are deliberately different
| Check | Create | Update |
|---|---|---|
| Required fields | enforced | skipped |
| Type compatibility | enforced | enforced |
| Column length | enforced | enforced |
| Constraint rules | enforced | enforced |
Required fields are skipped on update because an update sends only what changed. If a PUT with { "price": 29.99 } demanded every required column, partial updates would be impossible and every client would be forced to read-modify-write. The trade is real and worth naming: a row can be updated into a state that a create would have refused, if a required value was already missing.
Rules that span two columns
Single-column rules only get you so far. A start date before an end date, a discount that cannot exceed a total, an address where you need the postcode or the city but not neither — those are relationships between fields.
There are eleven of those: the six comparisons between two fields (less than, less than or equal, greater than, greater than or equal, equals, not equals), three set rules across a list of fields (at least one required, exactly one required, all or none), and two conditional rules that only have an opinion when another field says so — conditional required ("if the account is premium, a credit limit is required") and conditional range ("if the membership is gold, points must be between 1000 and 10000"). They live on the view, they can be reordered, and each can be disabled without deleting it — which is what you want during a bulk import that would otherwise trip every one of them.
The conditional pair is worth one more sentence, because the interesting part is what they do when the condition doesn't hold: nothing. A rule that only applies to premium accounts must be silent about every other account, and it has to reach that conclusion before it reads anything else. The condition is also compared as text, so a rule written against true still fires for a JSON boolean and one written against PREMIUM still fires for premium. Matching on Java types instead would make the rule quietly never fire — and a rule that never fires looks exactly like a row that passed.
These are enforced everywhere the column constraints are — including the generated REST API, on both writes. A create validates the payload, which is the whole row. An update is trickier, and the trickiness is worth understanding: an update payload is partial, so a rule comparing two fields may only see one of them. Validating the payload would let you update a row into violation through the field the rule cannot see. So updates are validated against the merged state — the row as it will be stored — which is the only honest interpretation of a cross-field rule.
(For most of this feature's life that paragraph would have been false: cross-field rules ran only inside the app, and the API could walk straight past them. Closing that was the point — the API is a first-class write path, so a rule that lives above it is a rule that can be walked around.)
What it doesn't do (yet)
- Eleven of eighteen. The database defines eighteen cross-field constraint types; eleven have validators, in both tiers. The other seven are not offered anywhere, because a constraint whose validator is missing is skipped — and a skipped constraint reads as a row that passed. An option that silently accepts everything is worse than a missing option, so they stay hidden until the validator exists.
- Two of the eleven can't be built in the app yet.
CONDITIONAL_REQUIRED("if the account is premium, a credit limit is required") andCONDITIONAL_RANGEare validated on every write path, but the rule builder can't configure them — it collects two fields, and a conditional rule needs a field, a value to match, and a target. They work today through the API and MCP, which take the configuration as given. The form is the missing piece, not the enforcement. - The seven without validators are: conditional constraint, composite unique, sum equals, sum range, date within range, date duration, and custom expression. Five of those seven are ordinary within-row arithmetic and could be written the same way the eleven were. The other two are not, and it's worth saying why.
- No composite uniqueness — and it shouldn't be a validator. "This combination of columns must be unique" is the one people ask for most, but every rule described in this post is a pure function of one row, and uniqueness is a question about the other rows. Checking it in the app means read-then-write, which is not atomic: two writes racing each other both look unique and both succeed. The honest fix is to generate a database unique index, which is exactly what we tell you to add by hand today — Postgres enforces it atomically, and no application-layer check can match that.
- No custom expressions. There is a type reserved for it and nothing behind it. It also needs a decision we haven't made: an expression language you can write into a rule is a surface worth designing deliberately rather than reaching for.
- Nothing is retroactive. Adding a constraint governs writes from that moment. Rows already in the table are not re-checked and will not be flagged.
Verified 26 Aug 2026: 205 tests, all green 2026-08-26, including 23 new ones for the two conditional rules — ConditionalValidatorTest (12) in metadata and the Conditional cases in EntityConstraintEnforcerTest (11) in workspace-api, written as deliberate mirrors so a rule means the same thing from the grid and from a program. The column tier — MetadataConstraintValidatorTest (55) in workspace-api, ConstraintValidatorTest (30) and BuiltInColumnConstraintValidatorTest (32) in metadata. Thirteen of those 55 are new, and they exist because this post was wrong — REQUIRED and the four date rules were enforced only in the app until 2026-08-26, so the "every writer" claim below did not hold for the generated API. They are enforced there now, and the new cases pin each one — including that REQUIRED refuses an explicitly-nulled field on update while still ignoring an absent one. The cross-field tier in the app — FieldComparisonValidatorTest (17), EitherOrValidatorTest (17), EntityDataValidationTest (12), EntityDataValidationSemanticsTest (2) — and now in the generated API through EntityConstraintEnforcerTest (19), whose semantics deliberately mirror the app tier's and whose wiring was verified by mutation — making the comparison accept equality failed the boundary test, and disabling the enforcer call on the create path failed the wiring test written after the first mutation round proved nothing was checking exactly that. The nine-of-eighteen count comes from the switch in EntityConstraintValidator.getValidator..