Criteria Based Validation
Every business has rules that the standard screens know nothing about. "Don't let anyone save a stock issue with a zero quantity." "Warn the salesman when he sells below cost." "Nobody except the warehouse supervisor may receive into the in-transit warehouse." "Once an invoice has been sent to the tax authority, the customer on it can never change again."
Criteria Based Validation is where you write those rules down. You pick a screen, describe the situation you want to catch, write the message the user should see, and from that moment on the system refuses to save (or warns, or asks for confirmation) whenever the situation occurs — on every screen, for every user, through the web client, the mobile app and imports alike.
You will find it under Basic → Settings → Criteria Based Validator.
The mental model: when … then …
A validator is a small stack of rules, and each rule reads like a sentence:
When the document is a credit sale, then the customer must have a credit limit.
The system evaluates the two halves separately:
- It checks the When half. If the situation doesn't apply to the record being saved, the rule is skipped entirely and nothing happens.
- If the When half holds, it checks the Then half. If Then holds too, all is well. If Then fails, the user gets your error message and the save is refused.
Both halves are optional. A rule with only a Then is an unconditional requirement ("quantity must never be zero"). A rule with only a When is a trap ("if this situation exists at all, refuse") — which is why so many real-world validators consist of nothing but a When query that returns 1 when something bad is true.
Each half can be expressed in two ways, and you can use both at once:
- When / Then — pick a saved criteria record, built with the ordinary filter builder. Readable, no SQL, but limited to what a filter can express.
- When Query / Then Query — a SQL query. Anything you can ask the database, you can validate.
The queries are what most implementations end up using, so the rest of this page concentrates on them.
Choosing what the validator watches
The header of the validator answers one question: which records does this thing look at? You have three mutually exclusive ways to answer it.
| Field | What it does |
|---|---|
| Target Type | A single screen, e.g. SalesInvoice, StockIssue, Employee. This is the usual choice. |
| Apply Also To | An Entity Type List record, so one validator can cover a whole family of screens (all the sales documents, all the accounts-receivable files, …). Can be combined with Target Type. |
| Applicable For | A blanket scope: All Screens, Documents, or Master Files. Use it for house rules like "the branch may never be left as PUBLIC on any document". |
Applicable For stands alone
If you fill Applicable For, you must leave both Target Type and Apply Also To empty — the system rejects the combination on save.
Priority decides the order when several validators watch the same screen: the higher the number, the earlier it runs. It matters mostly when you want the most important message to be the first one the user sees.
Error Message (Arabic) and Error Message (English) on the header are a convenience: whatever you type there is copied down into every rule line when you save.
The header message overwrites the lines
This copy is not a default — it is an overwrite. If you fill the header message, every line loses its own wording. Leave the header message empty whenever your lines say different things.
The rules grid
Each row in the grid is one rule. The columns:
| Column | Meaning |
|---|---|
| When / Then | Saved criteria records used as the two halves of the rule. |
| When Query / Then Query | SQL versions of the same two halves. |
| Lines Should Match | Evaluate the rule line by line inside the document instead of judging the document as a whole. See Working with document lines. |
| Validate with — Insert / Update / Draft / Revise / UnRevise / Delete / With Approval | The actions that trigger this rule. At least one must be ticked. |
| Approval Definition / Approval Steps (CSV) | Narrow a With Approval rule down to one approval cycle, and to specific step numbers (2,3). |
| Error Message (Arabic / English) | What the user reads. At least one language is required. |
| Error Message Content Query | An extra query whose columns you can drop into the message. |
| Error Field / Show Error By Line Number | Attach the error to a particular field, so the screen highlights it. |
| Validator Type | Error, Warning or Confirm. Empty means Error. |
| Inactive | Switch a single rule off without deleting it. |
| Remarks | A note to your future self. Nobody but you sees it. |
Writing the queries
The zero-or-one rule
A When/Then query is a question with a yes/no answer, and the answer is the first column of the first row the query returns:
- 0 (or
false) means no — the half did not hold. - Anything else —
1, a count greater than zero, a piece of text — means yes.
That is why nearly every validator in the field is written with the same shape:
select case when <the thing I want to be true> then 1 else 0 endFor a Then Query you phrase the condition positively — "this must be true" — and the user sees the error when it comes back 0. A stock issue that must never carry a zero quantity:
select case when {details.quantity.quantity.primeQty.value} = 0 then 0 else 1 endReading values off the record
Anything in curly braces is a field ID on the record being saved, and it is replaced by that record's value before the query is sent to the database. {code}, {valueDate}, {customer.id}, {details.item.item.code}, {lines.account.code} — the same paths you see in the field-ID picker everywhere else in the system.
A few placeholder tricks are worth knowing:
{something.$toReal.field}— reach through a generic reference (a field that can point at several different types) to the record it actually points at:{relatedEntity1.$toReal.id},{employee.$toReal.hiring}.{$user.code},{$creationDate}— the user who is saving, and the moment the record was created.{loginUserId},{loginEmployeeId},{loginLegalEntityId},{loginBranchId},{loginSectorId},{loginDepartmentId},{loginAnalysisSetId}and their…Code/…Name1/…Name2variants — the session the user is logged into. These are the same parameters the report engine exposes.
Comparing against the previous value
{oldData.…} gives you the value a field had before the current edit — the single most useful thing you can do in an Update rule. Catching any change to the remarks:
select case when {remarks} <> {oldData.remarks} then 1 else 0 endBlank values will trip this up, because a field that was empty and is now filled counts as a change. Guard against it:
select case when {remarks} <> {oldData.remarks} and coalesce({oldData.remarks}, '') <> '' then 1 else 0 endA real example — an invoice that has already gone to the tax authority may keep its customer forever:
-- When Query: has it been sent?
select case when {taxAuthoritySysFields.taxAuthEntityStatusType} = 'Sent' then 1 else 0 end
-- Then Query: the customer must still be the same one
select case when {oldData.customer.id} = {customer.id} then 1 else 0 endoldData only exists on an update
On insert there is no previous version, so {oldData.…} is empty. Tick Validate with → Update (and untick Insert) on rules built around it.
A query that returns nothing counts as "yes"
If a query returns no rows at all, the system treats the half as holding. That sounds like a footnote, but it is actually a favourite shortcut: append a where clause that only lets the row through in the situation you care about, and the rule quietly disappears everywhere else.
select case when datediff(day, {startDate}, getdate()) > -1 then 0 else 1 end
where {vacationType.code} = 'VT00001'For every other vacation type the query returns nothing, so no error is raised.
Working with document lines
Here is the part that surprises people. When a placeholder points into a grid — {details.item.item.id}, {lines.amount.value.amount} — the query is not run once. It is run once per line, with that line's values substituted. What the system then does with those per-line answers depends on Lines Should Match.
With Lines Should Match off, the document is judged as a whole:
- the When half holds only if no line answered 0;
- the Then half is violated as soon as any line answers 0.
With Lines Should Match on, each line is judged on its own. The When half holds if at least one line answers non-zero, and the error is raised for exactly those lines that passed When but failed Then. This is what you want for anything phrased as "no line may…", and it is by far the most common setting in real implementations.
Telling the user which line
Once a rule runs per line, the system knows the line numbers that failed and hands them to you as {$map.errorLines}:
Selling price is below cost — line {$map.errorLines}The placeholder expands to a comma-separated list of 1-based line numbers. At most 50 lines are reported; beyond that the list is truncated so a broken 5,000-line import doesn't drown the user in messages.
Highlighting the field itself
Instead of (or as well as) naming the line in the text, you can attach the error to a field so the screen jumps to it and marks it red:
- Error Field — the field ID to highlight, e.g.
details.price.unitPrice. - Show Error By Line Number — tick this when the error field lives in a grid. The system then raises one error per failing line, anchored to that line's cell.
The two settings travel together
A grid field without Show Error By Line Number is rejected when you save the validator, and ticking Show Error By Line Number without an Error Field is rejected too. The field must also really exist on the target screen — a typo is caught on save, not at runtime.
Making the message worth reading
"Invalid data" helps nobody. Three tools make the message specific.
Fields of the record can be dropped straight into the text:
User ({firstAuthor.name1}) is not allowed to receive into warehouse ({toWarehouse.name1})Error Message Content Query pulls in facts from elsewhere. Every column the query selects becomes a placeholder named after that column, usable in both messages. Telling the salesman what the credit limit actually is:
-- Error Message Content Query
select c.limitValue, c.paymentPeriod from Customer c where c.id = {customer.id}This customer has a credit limit of {limitValue} and a maximum payment period of {paymentPeriod}A clickable link to the offending record turns a complaint into a shortcut. Select the record's type, id and code in the content query, then wrap them:
-- Error Message Content Query
select top 1 entityType, id, code from MissionDocument
where employee_id = {employee.id} and id <> {id}The employee already has a mission document {titledlink(entityType,id)}{code}{endlink}Messages are full Tempo templates, so the rest of that syntax is available too.
Error, Warning or Confirm
The Validator Type decides how hard the rule pushes back.
Error is the default and the flat refusal: the message appears and nothing is saved.
Warning lets the save go through and shows the message anyway. Use it for things that are usually a mistake but legitimately happen — "another customer is already registered with this tax number", "this employee has been with us less than three months".
Confirm stops and asks. The user reads the message and either confirms and continues, or backs out. It is the right choice for judgement calls: selling below cost, paying a customer into a negative balance, creating a second mission document over the same dates.
Confirm only works on save and delete
There is no confirmation dialog behind a draft, a revise, an unrevise or an approval step, so a Confirm rule may not tick Draft, Revise, UnRevise or With Approval. The system refuses to save the validator if it does. Confirmations are also skipped during imports.
When the check runs
The Validate with columns are what connect a rule to an action. Tick only what you mean:
- Insert — the record is being committed for the first time.
- Update — an already-committed record is being committed again.
- Draft — the user is saving a draft. Untick it if drafts are meant to be a scratchpad.
- Delete — the user is deleting the record. This is where you protect referenced data: "this task appears on a meeting-minutes document and cannot be deleted".
- Revise / UnRevise — the document is being marked reviewed, or that mark is being removed.
- With Approval — someone is acting on an approval step. Combine it with Approval Definition and Approval Steps (CSV) to fire only inside a particular cycle, at particular steps. See Approvals.
Validators do not run in the background
Business requests processed in the background — the accounting and inventory effects of a document — never run criteria based validation. Validation happens while a human (or an import) is saving the record, not while its effects are being processed later.
Limiting the check to certain people
Sometimes a rule is really about authority: everyone must respect it except the branch manager, or only the two data-entry users are subject to it. That is what Policy and the Selected Group grid are for.
Fill the grid with users, user groups, or security profiles — any mix — then choose:
- Apply On Selected — the rule affects only the people in the list.
- Apply On All Except Selected — the rule affects everybody else.
Leave the grid empty and the rule applies to everyone, whatever the policy says.
Prefer groups and profiles to user lists
Listing individual users works, and plenty of live systems do it — but every new hire then means editing the validator. A user group or a security profile keeps the rule stable.
Only check when something actually changed
The Critical Fields grid at the bottom is a filter on updates. List a few field IDs there and the validator is skipped on update unless at least one of those fields changed. Insert, delete and revise are unaffected — the rule always runs for them.
It is the cure for the validator that keeps blocking a document because of a condition nobody on this edit touched. If the rule is about the item's "free goods" flag, list details.item.item.b1 and the rule stops interfering with edits to the delivery address.
Switching things off
Three separate switches, at three levels:
- Inactive on the header takes the whole validator out of circulation — and, as a convenience, ticks Inactive on every one of its lines when you save.
- Inactive on a line disables that single rule.
- Inactive With Replication keeps the validator active for normal users but skips it while data is arriving through replication — useful when a rule is about data entry discipline rather than data integrity, and the sending site has already enforced it.
Ignore Validation On Approval on the header does something narrower: it skips the whole validator when the record is being committed as the result of an approval cycle finishing. The document was already checked when it was submitted; this stops the same complaint from blocking the approver.
The script fields are retired
Validation Script, Effect Script and Delete Script still appear on old records but can no longer be used — saving a validator that fills them fails with a message telling you to replace them with an Entity Flow.
Worked examples
The patterns below are drawn from live implementations, with the wording translated and the codes made generic.
Require a value
The simplest useful validator: no line of a stock issue may carry a zero quantity.
- Target Type:
StockIssue - Validate with: Insert, Draft
- Then Query:
select case when {details.quantity.quantity.primeQty.value} = 0 then 0 else 1 end- Message: You must enter a quantity for every item.
Require a value only in a particular case
When the country is "Other", the free-text country name becomes mandatory.
- Target Type:
Employee - When Query:
select case when {contactInfo.address.country} = 'Other' then 1 else 0 end- Then Query:
select case when {description4} = '' then 0 else 1 end- Message: Please write the country name when you choose "Other".
Prevent a duplicate
No two employees may share a bank account, no two documents the same manual reference. The shape is always a count against the same table with id <> {id} so the record doesn't collide with itself.
- Target Type:
PurchaseInvoice - Then Query:
select case when count(1) > 0 then 0 else 1 end
from PurchaseInvoice e where e.manualRef1 = {manualRef1} and e.id <> {id}- Error Message Content Query:
select top 1 code from PurchaseInvoice e where e.manualRef1 = {manualRef1} and e.id <> {id}- Message: This manual reference is already used by invoice {code}.
Block a change after a point of no return
Once the invoice has been reported to the tax authority, the customer is frozen. Shown in full under Comparing against the previous value above.
Catch a bad line and point at it
No line may be sold below its average cost — reported per line, as a confirmation rather than a hard stop, because management sometimes approves it.
- Target Type:
SalesInvoice - Lines Should Match: ticked
- Validator Type: Confirm
- When Query:
select top 1 case when coalesce(q.avgCost, 0) > {details.price.unitPrice} then 1 else 0 end
from ItemDimensionCost q where q.item_id = {details.item.item.id}- Then Query — services have no cost, so exclude them:
select case when {details.item.item.itemType} = 'Service' then 1 else 0 end- Message: Selling price is below cost on line {$map.errorLines}.
Restrict an operation to certain people
Only a named set of users may receive into each warehouse. One rule per warehouse, all in the same validator.
- Target Type:
ReceiptStockTransfer - When Query:
select case when {toWarehouse.code} = 'W0103' then 1 else 0 end- Then Query:
select case when {firstAuthor.code} in ('10428', '10431', '10045') then 1 else 0 end- Message: User ({firstAuthor.name1}) is not allowed to receive into warehouse ({toWarehouse.name1}).
Enforce a house rule on every document
The analysis dimensions must be filled on every document in the system, except the HR documents that legitimately run on the public dimension.
- Applicable For: Documents
- When Query:
select case when {entityType} in
('SalarySheet','TimeAttendance','LeavePermission','MissionDocument','VacationDocument')
then 0 else 1 end- Then Query:
select case when {analysisSet.code} = 'PUBLIC' then 0 else 1 end- Message: Please choose a project.
Guard a deletion
A task that has been written into meeting minutes cannot simply disappear.
- Target Type:
WorkTask - Validate with: Delete
- When Query:
select case when count(1) > 0 then 1 else 0 end
from MeetingRemarkLine mrl where mrl.ref1id = {id}- Error Message Content Query:
select top 1 mr.code as mr_code, mr.remarkDate as mr_remarkDate
from MeetingRemarkLine mrl
left join MeetingRemark mr on mr.id = mrl.meetingRemark_id
where mrl.ref1id = {id}- Message: This task is listed on meeting document {mr_code} dated {mr_remarkDate} and cannot be deleted.
Warn without blocking
The customer's tax policy is missing — worth mentioning, not worth refusing the sale over.
- Target Type:
SalesInvoice - Validator Type: Warning
- Then Query:
select case when {customer.taxPlan} is not null then 0 else 1 end- Message: Please check the tax policy on the customer file.
Checks the system runs on your validator
The validator itself is validated when you save it, which catches most mistakes before any user ever meets them. Each line must carry a message in at least one language, must have at least one of the four When/Then boxes filled, and must tick at least one Validate with action. The header must name a Target Type, an Apply Also To list or an Applicable For scope — and Applicable For may not be combined with the other two. An Error Field must exist on the target screen, and a Confirm line may not be tied to draft, revise, unrevise or approval.
Related pages
- Criteria from Text Parser — the filter syntax behind saved criteria records.
- Tempo — the template syntax used in error messages.
- Entity Flows — for when you need to do something rather than refuse something.
- Approvals — approval cycles, definitions and steps.
- Input validation in Fields and Entities Settings — for rules about a single field's shape rather than the document's meaning. The Field Formats grid there enforces "a tax number is fifteen digits" or "a mobile number starts with 05" without a query, and each row can be gated by a criteria record so the format only applies in the situations you choose.