Validation rules enforce data quality and business rules at the metadata level. They ensure data integrity before it reaches the database. Rules are designed to be both machine-executable and AI-understandable, with clear business intent.
ObjectQL's validation system provides:
Field-level validation : Built-in type validation (email, URL, range, etc.)
Cross-field validation : Validate relationships between fields
Business rule validation : Declarative rules with clear intent
Async validation : External API validation (uniqueness, external system checks)
Conditional validation : Rules that apply only in specific contexts
State machine validation : Enforce valid state transitions
File Naming Convention: <object_name>.validation.yml
The filename (without the .validation.yml extension) automatically identifies which object these validation rules apply to. This eliminates the need for redundant name and object properties.
Examples:
project.validation.yml → Applies to object: project
customer_order.validation.yml → Applies to object: customer_order
# File: project.validation.yml
# Object "project" is inferred from filename!
description : "Validation rules for project object"
# AI-friendly context (optional)
ai_context :
intent : "Ensure project data integrity and enforce business rules"
validation_strategy : "Fail fast with clear error messages"
# Validation Rules
rules :
# Rule 1: Cross-Field Validation with AI Context
- name : valid_date_range
type : cross_field
# AI context explains the business rule
ai_context :
intent : "Ensure timeline makes logical sense"
business_rule : "Projects cannot end before they start"
error_impact : high # high, medium, low
# Declarative rule (AI can generate implementations)
rule :
field : end_date
operator : ">="
compare_to : start_date
message : "End date must be on or after start date"
error_code : "INVALID_DATE_RANGE"
# Rule 2: Business Rule with Intent
- name : budget_limit
type : business_rule
ai_context :
intent : "Prevent projects from exceeding department budget allocation"
business_rule : "Each department has a budget limit. Individual projects cannot exceed it."
data_dependency : "Requires department.budget_limit field"
examples :
valid :
- project_budget : 50000
department_budget_limit : 100000
invalid :
- project_budget : 150000
department_budget_limit : 100000
# Declarative constraint (AI can optimize implementation)
constraint :
expression : "budget <= department.budget_limit"
relationships :
department :
via : department_id
field : budget_limit
message : "Project budget (${{budget}}) exceeds department limit (${{department.budget_limit}})"
error_code : "BUDGET_EXCEEDS_LIMIT"
trigger :
- create
- update
fields :
- budget
- department_id
# Rule 3: State Machine with Transitions
- name : status_transition
type : state_machine
field : status
ai_context :
intent : "Control valid status transitions throughout project lifecycle"
business_rule : "Projects follow a controlled workflow"
visualization : |
planning → active → completed
↓ ↓
cancelled ← on_hold
transitions :
planning :
allowed_next : [ active , cancelled ]
ai_context :
rationale : "Can start work or cancel before beginning"
active :
allowed_next : [ on_hold , completed , cancelled ]
ai_context :
rationale : "Can pause, finish, or cancel ongoing work"
on_hold :
allowed_next : [ active , cancelled ]
ai_context :
rationale : "Can resume or cancel paused projects"
completed :
allowed_next : []
is_terminal : true
ai_context :
rationale : "Finished projects cannot change state"
cancelled :
allowed_next : []
is_terminal : true
ai_context :
rationale : "Cancelled projects are final"
message : "Invalid status transition from {{old_status}} to {{new_status}}"
error_code : "INVALID_STATE_TRANSITION"
Field validations are defined directly in the object definition with AI context:
# In object.yml
fields :
email :
type : email
required : true
validation :
format : email
message : Please enter a valid email address
ai_context :
intent : "User's primary contact email"
validation_rationale : "Email format required for notifications"
age :
type : number
validation :
min : 0
max : 150
message : Age must be between 0 and 150
ai_context :
intent : "Person's age in years"
validation_rationale : "Realistic human age range"
examples : [ 25 , 42 , 67 ]
username :
type : text
required : true
validation :
min_length : 3
max_length : 20
pattern : "^[a-zA-Z0-9_]+$"
message : "Username must be 3-20 alphanumeric characters or underscores"
ai_context :
intent : "Unique user identifier for login"
validation_rationale : "Prevent special characters that cause URL issues"
examples : [ "john_doe" , "alice123" , "bob_smith" ]
avoid : [ "user@123" , "test!" , "a" ] # Too short or invalid chars
regex: ^[a-zA-Z0-9_]+$
message: Username must be 3-20 alphanumeric characters or underscores
website:
type: url
validation:
format: url
protocols: [http, https]
message: Please enter a valid URL
ai_context:
intent: "Company or personal website"
examples: ["https://example.com ", "https://www.company.com "]
password:
type: password
required: true
validation:
min_length: 8
regex: ^(?=.[a-z])(?=. [A-Z])(?=.\d)(?=. [@! !%*?&])[A-Za-z\d@ ! !%*?&]
message: Password must contain uppercase, lowercase, number and special character
ai_context:
intent: "Secure user password"
validation_rationale: "Strong password policy for security compliance"
### 3.2 Cross-Field Validation
Validate relationships between multiple fields with clear business intent:
```yaml
rules:
# Date comparison with AI context
- name: end_after_start
type: cross_field
ai_context:
intent: "Ensure logical timeline"
business_rule: "Events/projects cannot end before they start"
error_impact: high
rule:
field: end_date
operator: ">="
compare_to: start_date
message: "End date must be on or after start date"
error_code: "INVALID_DATE_RANGE"
severity: error
# Conditional requirement with reasoning
- name: reason_required_for_rejection
type: cross_field
ai_context:
intent: "Require explanation for rejections"
business_rule: "Users must document why something was rejected"
compliance: "Audit trail requirement"
rule:
if:
field: status
operator: "="
value: rejected
then:
field: rejection_reason
operator: "!="
value: null
message: "Rejection reason is required when status is 'rejected'"
error_code: "REJECTION_REASON_REQUIRED"
# Sum validation with business context
- name: total_percentage
type: cross_field
ai_context:
intent: "Ensure percentages add up correctly"
business_rule: "Distribution must total 100%"
examples:
valid:
- discount: 20, tax: 10, other: 70 # = 100
invalid:
- discount: 20, tax: 10, other: 60 # = 90
rule:
expression: "discount_percentage + tax_percentage + other_percentage"
operator: "="
value: 100
message: "Total percentage must equal 100% (currently: {{sum}}%)"
error_code: "INVALID_PERCENTAGE_SUM"
Declarative business rules that AI can understand and optimize:
rules :
# Declarative business rule
- name : budget_within_limits
type : business_rule
ai_context :
intent : "Prevent budget overruns"
business_rule : "Project budgets must be within approved department limits"
data_source : "department.annual_budget_limit"
decision_logic : |
If project.budget > department.budget_limit:
- Require executive_approval = true
- Or reject with error
# AI can generate optimal implementation
constraint :
expression : "budget <= department.budget_limit OR executive_approval = true"
relationships :
department :
via : department_id
fields : [ budget_limit ]
message : "Budget exceeds department limit (${{department.budget_limit}}). Executive approval required - please add executive_approval_id field and route to executive for review."
error_code : "BUDGET_LIMIT_EXCEEDED"
trigger : [ create , update ]
fields : [ budget , department_id , executive_approval ]
# Multi-condition business rule
- name : manager_approval_required
type : business_rule
ai_context :
intent : "Enforce approval policy for high-value transactions"
business_rule : |
Transactions require manager approval if:
- Amount > $10,000 OR
- Customer is flagged as high-risk OR
- Payment terms exceed 60 days
approval_matrix :
- amount > 10000 : requires manager
- amount > 50000 : requires director
- amount > 200000 : requires executive
constraint :
any_of :
- field : amount
operator : ">"
value : 10000
- field : customer.risk_level
operator : "="
value : high
- field : payment_terms_days
operator : ">"
value : 60
then_require :
- field : manager_approved_by
operator : "!="
value : null
message : "Manager approval required for this transaction"
error_code : "APPROVAL_REQUIRED"
For complex logic that can't be expressed declaratively, provide implementation with clear intent:
rules :
# Custom validation with AI-understandable intent
- name : credit_check
type : custom
ai_context :
intent : "Verify customer has sufficient credit"
business_rule : "Total outstanding + new order cannot exceed credit limit"
external_dependency : "Customer credit system"
algorithm : |
1. Fetch customer's current outstanding balance
2. Add proposed order amount
3. Compare to customer credit limit
4. Reject if would exceed limit
message : "Customer credit limit exceeded"
error_code : "CREDIT_LIMIT_EXCEEDED"
severity : error
trigger : [ create , update ]
fields :
- amount
- customer_id
validator : |
async function validate(record, context) {
const customer = await context.api.findOne('customers', record.customer_id);
const totalOrders = await context.api.sum('orders', 'amount', [
['customer_id', '=', record.customer_id],
['status', 'in', ['pending', 'processing']]
]);
return (totalOrders + record.amount) <= customer.credit_limit;
}
error_message_template : "Order amount ${amount} exceeds customer credit limit ${customer.credit_limit}"
# External API validation
- name : tax_id_verification
type : custom
message : Invalid tax ID
async : true
validator : |
async function validate(record, context) {
const response = await context.http.post('https://api.tax.gov/verify', {
tax_id: record.tax_id
});
return response.data.valid;
}
Ensure field values are unique:
rules :
# Simple uniqueness
- name : unique_email
type : unique
field : email
message : Email address already exists
case_sensitive : false
# Composite uniqueness
- name : unique_name_per_project
type : unique
fields :
- project_id
- name
message : Task name must be unique within project
# Conditional uniqueness
- name : unique_active_subscription
type : unique
field : user_id
message : User already has an active subscription
scope :
field : status
operator : "="
value : active
Control valid state transitions:
rules :
- name : order_status_flow
type : state_machine
field : status
message : Invalid status transition
initial_states :
- draft
transitions :
draft :
- submitted
- cancelled
submitted :
- approved
- rejected
approved :
- processing
- cancelled
processing :
- shipped
- on_hold
- cancelled
shipped :
- delivered
- returned
delivered :
- returned # Within 30 days
on_hold :
- processing
- cancelled
# Terminal states
returned : []
cancelled : []
# Additional conditions
transition_conditions :
shipped_to_delivered :
from : shipped
to : delivered
condition :
# Can only mark as delivered after 1 day
field : shipped_date
operator : "<"
value : $current_date - 1 day
Validate related record constraints:
rules :
# Parent record validation
- name : active_project_required
type : dependency
message : Cannot create task for inactive project
condition :
lookup :
object : projects
match_field : project_id
validate :
field : status
operator : "="
value : active
# Child record validation
- name : cannot_delete_with_tasks
type : dependency
message : Cannot delete project with active tasks
trigger : [ delete ]
condition :
has_related :
object : tasks
relation_field : project_id
filter :
- field : status
operator : "!="
value : completed
rules :
# Error - Blocks save
- name : required_field_check
type : custom
severity : error
message : Critical field missing
# Warning - Shows warning but allows save
- name : recommended_field
type : custom
severity : warning
message : It's recommended to fill this field
# Info - Just informational
- name : data_quality_suggestion
type : custom
severity : info
message : Consider adding more details
Control when validation rules execute:
rules :
- name : budget_approval_check
type : custom
# Only run on specific operations
trigger :
- create
- update
# Only run when specific fields change
fields :
- budget
- department_id
# Only run in specific contexts
context :
- ui # From UI forms
- api # From API calls
# Skip in bulk operations
skip_bulk : true
validator : |
function validate(record) {
return record.budget <= 100000 || record.approval_status === 'approved';
}
Organize related validation rules:
validation_groups :
# Basic data quality
- name : data_quality
description : Basic field validation
rules :
- required_fields
- valid_formats
- value_ranges
# Business rules
- name : business_logic
description : Business rule validation
rules :
- credit_check
- inventory_check
- pricing_rules
# Compliance
- name : compliance
description : Regulatory compliance
rules :
- gdpr_consent
- data_retention
- audit_trail
# Advanced
- name : advanced
description : Complex validation (may be slow)
rules :
- external_api_checks
- complex_calculations
# Run asynchronously
async : true
# Can be skipped for performance
required : false
Rules that only apply in certain contexts:
rules :
- name : international_shipping_validation
type : custom
message : International orders require customs declaration
# Only apply when shipping internationally
apply_when :
field : shipping_country
operator : "!="
value : US
validator : |
function validate(record) {
return record.customs_declaration !== null;
}
- name : high_value_approval
type : custom
message : Orders over $10,000 require manager approval
# Only apply for high-value orders
apply_when :
field : total_amount
operator : ">"
value : 10000
validator : |
function validate(record) {
return record.manager_approval_id !== null;
}
For validation requiring external API calls or complex queries:
rules :
- name : email_deliverability
type : async
message : Email address is not deliverable
async : true
timeout : 5000 # 5 second timeout
validator : |
async function validate(record, context) {
try {
const result = await context.http.post('https://api.emailvalidation.com/check', {
email: record.email
});
return result.data.deliverable;
} catch (error) {
// On timeout or error, allow (fail open)
return true;
}
}
- name : inventory_available
type : async
message : Insufficient inventory
validator : |
async function validate(record, context) {
const inventory = await context.api.findOne('inventory', {
filters: [['sku', '=', record.sku]]
});
return inventory.available_quantity >= record.quantity;
}
rules :
- name : simple_rule
type : custom
message : This is a simple error message
Use placeholders for dynamic messages:
rules :
- name : template_message
type : custom
message : "Field ${field_name} must be between ${min} and ${max}"
message_params :
field_name : amount
min : 0
max : 1000
Generate messages dynamically:
rules :
- name : dynamic_message
type : custom
message : |
function getMessage(record, context) {
return `Budget $${record.budget} exceeds department limit $${context.department.limit}`;
}
rules :
- name : i18n_message
type : custom
message :
en : Please enter a valid email address
zh-CN : 请输入有效的电子邮件地址
es : Por favor, introduce una dirección de correo electrónico válida
Validators receive a rich context object:
interface ValidationContext {
// Current record data
record : any ;
// Previous data (for updates)
previousRecord ?: any ;
// Current user
user : User ;
// API access
api : ObjectQLAPI ;
// HTTP client for external calls
http : HttpClient ;
// Operation type
operation : 'create' | 'update' | 'delete' ;
// Additional metadata
metadata : {
objectName : string ;
ruleName : string ;
};
}
validation :
# Cache validation results
cache :
enabled : true
ttl : 300 # 5 minutes
# Cache key includes these fields
cache_key_fields :
- id
- updated_at
rules :
- name : batch_inventory_check
type : custom
# Support batch validation
batch_enabled : true
batch_size : 100
validator : |
async function validateBatch(records, context) {
// Validate multiple records in one query
const skus = records.map(r => r.sku);
const inventory = await context.api.find('inventory', {
filters: [['sku', 'in', skus]]
});
// Return validation result for each record
return records.map(record => {
const inv = inventory.find(i => i.sku === record.sku);
return inv && inv.available_quantity >= record.quantity;
});
}
Using the Validator class from @objectql/core:
import { Validator } from '@objectql/core' ;
import {
ValidationContext,
CrossFieldValidationRule,
StateMachineValidationRule,
ObjectConfig
} from '@objectql/types' ;
// Create validator instance
const validator = new Validator ({
language: 'en' ,
languageFallback: [ 'en' , 'zh-CN' ]
});
// Define object with validation rules
const orderObject : ObjectConfig = {
name: 'order' ,
fields: {
subtotal: { type: 'currency' },
tax: { type: 'currency' },
total: { type: 'currency' },
customer_id: { type: 'lookup' , reference_to: 'customers' }
},
validation: {
rules: [
{
name: 'valid_total' ,
type: 'cross_field' ,
rule: {
expression: 'total === subtotal + tax'
},
message: 'Total must equal subtotal + tax' ,
error_code: 'INVALID_TOTAL'
}
]
}
};
// Programmatic validation example
const rules : CrossFieldValidationRule [] = [
{
name: 'valid_total' ,
type: 'cross_field' ,
rule: {
field: 'total' ,
operator: '=' ,
value: 150 // Or use compare_to for cross-field
},
message: 'Total must equal subtotal + tax'
}
];
const context : ValidationContext = {
record: {
subtotal: 100 ,
tax: 50 ,
total: 150 ,
customer_id: 'cust-123'
},
operation: 'create'
};
const result = await validator. validate (rules, context);
if ( ! result.valid) {
console. log ( 'Validation failed:' , result.errors);
// Output: Array of ValidationRuleResult objects with:
// - rule: string (rule name)
// - valid: boolean
// - message: string
// - error_code: string
// - severity: 'error' | 'warning' | 'info'
// - fields: string[]
}
// Field-level validation example
import { FieldConfig } from '@objectql/types' ;
const emailField : FieldConfig = {
type: 'email' ,
required: true ,
validation: {
format: 'email' ,
message: 'Please enter a valid email address'
}
};
const fieldResults = await validator. validateField (
'email' ,
emailField,
'invalid-email' ,
context
);
// State machine validation example
const statusRule : StateMachineValidationRule = {
name: 'order_status_flow' ,
type: 'state_machine' ,
field: 'status' ,
transitions: {
draft: {
allowed_next: [ 'submitted' , 'cancelled' ]
},
submitted: {
allowed_next: [ 'approved' , 'rejected' ]
},
approved: {
allowed_next: [ 'processing' ]
},
processing: {
allowed_next: [ 'shipped' , 'cancelled' ]
},
shipped: {
allowed_next: [ 'delivered' ],
is_terminal: false
},
delivered: {
allowed_next: [],
is_terminal: true
}
},
message: 'Invalid status transition from {{old_status}} to {{new_status}}'
};
const updateContext : ValidationContext = {
record: { status: 'approved' },
previousRecord: { status: 'submitted' },
operation: 'update'
};
const statusResult = await validator. validate ([statusRule], updateContext);
Note on Stub Implementations:
The following validation types have stub implementations that pass silently (return valid: true without messages):
unique - Uniqueness validation (requires database access)
business_rule - Complex business rules (requires expression evaluation)
custom - Custom validation functions (requires safe function execution)
dependency - Related record validation (requires database queries)
These will be implemented in future updates when database and expression evaluation capabilities are integrated.
Validate Early : Catch errors before database operations
Clear Messages : Provide actionable error messages
Performance : Minimize async validations, use caching
User Experience : Use severity levels appropriately (error vs warning)
Testing : Test validation rules with edge cases
Documentation : Document complex validation logic
Reusability : Create reusable validation functions
Fail Fast : Order rules by likelihood of failure
validation :
# Error handling strategy
on_error :
# Collect all errors vs fail on first
mode : collect_all # or 'fail_fast'
# Maximum errors to collect
max_errors : 10
# Include field path in errors
include_field_path : true
# Format
error_format :
type : structured
include_rule_name : true
include_severity : true