22.1 Rules vs Validation vs Guards
ADL has three data integrity mechanisms. Each operates at a different scope:
| Feature | Scope | Timing | Question It Answers |
|---|---|---|---|
| Validation (§14) | Single record, single/multi-field | Before write | ”Are these field values valid?” |
| State machine guards (§12.3) | Single record | Before transition | ”Can this user perform this transition?” |
| Rules | Cross-resource | Before write | ”Does this operation violate a business policy?” |
Validation checks data shape — required fields, patterns, value ranges, date ordering. Guards check transition eligibility — role-based access and field presence. Rules check business policies that span multiple records, multiple resources, and aggregate constraints that no single record can express.
Rules evaluate BEFORE the write commits. If a reject rule fires, the write never reaches the database. This is critical — a 100% effort cap must be enforced before the commitment is recorded, not after.
22.2 Declaration
Rules are declared at the domain level because they span resources. Each rule is a named entry in the rules: block:
domain:
name: awards
version: "2.0.0"
rules:
effortCapEnforcement:
description: "Total effort across all grants cannot exceed 100%"
trigger:
resource: EffortCommitment
on: [create, update]
condition: >
sum(EffortCommitment, 'committedPercent',
personnelId == $record.personnelId
&& id != $record.id
&& status != 'certified')
+ $record.committedPercent > 100
action: reject
message: "Total committed effort for {personnelName} would be {totalEffort}%, exceeding 100%"
messageData:
totalEffort: >
sum(EffortCommitment, 'committedPercent',
personnelId == $record.personnelId
&& id != $record.id
&& status != 'certified')
+ $record.committedPercent
severity: error
regulation: "2 CFR §200.430 — Compensation"
Rule Properties
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
description | string | yes | — | Human-readable description of the business rule |
trigger | object | yes | — | When to evaluate the rule |
condition | string | yes | — | AEL expression — the rule fires when this evaluates to true |
action | string | yes | — | reject, warn, or log |
message | string | yes | — | Human-readable message with {field} interpolation |
messageData | object | no | {} | Named computed values for message interpolation |
severity | string | yes | — | error (reject), warning (warn), info (log) |
regulation | string | no | "" | Regulatory citation for compliance documentation |
enabled | boolean | no | true | Can be disabled at runtime via the rules API |
priority | integer | no | 100 | Evaluation order — lower numbers evaluate first |
dependsOn | string | no | "" | Skip this rule if the named prerequisite rule fired |
group | string | no | "" | Rule group name for organizational clarity and bulk operations |
notification | object | no | — | Fire a notification when the rule triggers (§23) |
history | object | no | — | Record a history event when the rule triggers (§21) |
22.3 Rule Actions
Reject — Hard Rule
action: reject
severity: error
The operation is blocked. The API returns HTTP 422:
{
"status": "error",
"code": 422,
"error_code": "RULE_VIOLATION",
"error_message": "Total committed effort for Dr. Smith would be 115%, exceeding 100%",
"rule": "effortCapEnforcement",
"regulation": "2 CFR §200.430 — Compensation",
"severity": "error"
}
The record is not created, updated, or transitioned. The operation fails atomically — no partial writes. Reject is for hard constraints that must never be violated: effort caps, enrollment limits, budget ceilings, safety gates.
Warn — Soft Rule
action: warn
severity: warning
The operation proceeds, but the response includes a warning:
{
"status": "ok",
"code": 201,
"data": { ... },
"warnings": [
{
"rule": "procurementThresholdWarning",
"message": "Expenditure of $32,500.00 exceeds $25,000. Competitive bidding documentation required.",
"regulation": "2 CFR §200.320 — Procurement",
"severity": "warning"
}
]
}
The record is written. The warning is returned in the response body. If a notification is configured on the rule, it fires. If a history event is configured, it is recorded. Warn is for policies that require awareness but not prevention: threshold crossings, documentation requirements, review triggers.
Log — Informational Rule
action: log
severity: info
The operation proceeds silently. The rule evaluation result is recorded in the rules log table but no warning is returned in the API response. Log is for monitoring and analytics: large transaction flagging, pattern detection, compliance audit trails.
22.4 Trigger Configuration
The trigger object defines when a rule is evaluated:
trigger:
resource: Expenditure
on: [create, update]
fields: [amount, vendor]
condition: "$record.status == 'pending'"
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
resource | string | yes | — | The resource that triggers rule evaluation |
on | string or string[] | yes | — | Operations: create, update, transition, delete |
fields | string[] | no | all | For update: evaluate only when these fields change |
transition | string | no | — | For transition: specific transition name |
condition | string | no | "" | Pre-filter AEL expression — skip evaluation entirely if false |
Pre-Filter Condition
The trigger.condition is a fast single-record check that prevents the expensive cross-resource condition from evaluating when the record obviously does not apply:
trigger:
resource: Participant
on: [create]
condition: "$record.status == 'enrolled' || $record.status == 'screening'"
If the participant’s status is neither enrolled nor screening, the main condition (which queries a count across all participants) is never evaluated.
Field-Scoped Triggers
For on: update, the fields property ensures the rule only evaluates when specific fields change:
trigger:
resource: EffortCommitment
on: [create, update]
fields: [committedPercent, personnelId]
An effort cap rule does not need to re-evaluate when someone updates a comment field.
22.5 Condition Language
Rule conditions use the full AEL expression engine with cross-resource query functions that are not available in validation rules.
Aggregation Functions
sum(Resource, 'field', filter) — sum field values across records
count(Resource, filter) — count matching records
avg(Resource, 'field', filter) — average field value
min(Resource, 'field', filter) — minimum field value
max(Resource, 'field', filter) — maximum field value
exists(Resource, filter) — true if any record matches
Lookup Functions
findOne(Resource, filter) — return single matching record
findOne(Resource, filter).field — return a specific field from match
findMany(Resource, filter) — return all matching records
Record Context
$record — the record being created/updated/transitioned
$record.field — a specific field on the record
$previous.field — field value before the update (update only)
$transition — transition name (transition only)
Temporal Functions
now() — current timestamp
daysBetween(date1, date2) — days between two dates
daysUntil(date) — days from now to date
yearsBetween(date1, date2) — years between two dates
Condition Examples
Aggregate constraint — effort cap:
condition: >
sum(EffortCommitment, 'committedPercent',
personnelId == $record.personnelId
&& id != $record.id
&& status != 'certified')
+ $record.committedPercent > 100
Cross-resource lookup — enrollment cap:
condition: >
count(Participant,
studyId == $record.studyId
&& (status == 'enrolled' || status == 'screening'))
>= findOne(Study, id == $record.studyId).targetSampleSize
Existence check — conflict of interest:
condition: >
exists(ConflictOfInterest,
personnelId == $record.traderId
&& entity == $record.venue
&& status == 'active')
Temporal constraint — inspection recency:
condition: >
count(Inspection,
assetId == $record.id
&& status == 'approved'
&& daysBetween(performedDate, now()) <= 90)
== 0
22.6 Message Interpolation
Rule messages support {fieldName} placeholders resolved from the record and from messageData.
messageData
The messageData object defines named values computed at evaluation time and available in the message template:
message: "Total committed effort for {personnelName} would be {totalEffort}%, exceeding 100%"
messageData:
totalEffort: >
sum(EffortCommitment, 'committedPercent',
personnelId == $record.personnelId
&& id != $record.id)
+ $record.committedPercent
{personnelName} resolves from $record.personnelName. {totalEffort} resolves from the messageData computation. This separation keeps the condition (a boolean test) clean while providing rich context in the message.
Regulatory Citations
The regulation field provides a machine-readable citation that appears in rejection responses and the rules log:
regulation: "2 CFR §200.430 — Compensation"
When an auditor asks “what controls prevent over-commitment of effort?” the answer is in the YAML — the policy, the enforcement, and the regulatory citation are co-located in the rule definition.
22.7 Evaluation Order
When multiple rules apply to the same operation on the same resource:
1. Rules sorted by priority (lower first, default 100)
2. All reject rules evaluated first
3. If any reject rule fires → operation blocked, no further rules evaluated
4. All warn rules evaluated → warnings collected
5. All log rules evaluated → log entries recorded
This ordering ensures hard rules are checked before soft rules, a single rejection is sufficient to block, all applicable warnings are collected together, and log rules never affect the operation outcome.
Rule Dependencies
A rule with dependsOn is skipped if the named prerequisite rule fired:
subawardOverrunCheck:
dependsOn: subawardBudgetConsistency
If subawardBudgetConsistency rejected the operation, subawardOverrunCheck is skipped — preventing redundant messages about budget details when the operation was already rejected for a more fundamental violation.
22.8 Execution Position
Rules sit at position 6 in the write pipeline — after all single-record checks pass but before the write commits:
1. Authentication
2. Permission check
3. Field-level permission check
4. Field-level + cross-field validation
5. State machine guard check (if transition)
6. RULES EVALUATION ← reject / warn / log
7. Write to database
8. Computed field recalculation
9. Version / audit recording
10. History event emission
11. Notification dispatch
12. Workflow trigger evaluation
This position means rules only evaluate for operations that are otherwise valid, rules can reference the complete $record with merged request data, reject rules prevent the write atomically, and history events and notifications fire AFTER rules pass.
22.9 Rules Log
Every rule evaluation that fires is recorded in the {domain}_rules_log table:
| Column | Type | Description |
|---|---|---|
id | uuid | Log entry identifier |
ruleName | string | Rule definition name |
action | string | reject, warn, or log |
fired | boolean | Whether the condition evaluated to true |
message | string | Interpolated message |
regulation | string | Regulatory citation |
resourceName | string | Triggering resource |
recordId | uuid | Triggering record |
actorId | uuid | User who performed the operation |
operation | string | create, update, transition, delete |
timestamp | datetime | When the evaluation occurred |
The rules log is the compliance evidence trail. When an auditor asks “how many times was the effort cap triggered?” the answer is a single query against the log table.
Verbose Logging
By default, only triggered rules (condition = true) are logged. Verbose logging records every evaluation including non-fires:
domain:
rulesConfig:
logAllEvaluations: false
logRetentionDays: 365
22.10 Rules API
List Rules
GET /api/v1/{domain}/rules
Returns all rule definitions with fire counts and last-fired timestamps.
Get Rule
GET /api/v1/{domain}/rules/{name}
Returns one rule definition with statistics.
Rule Log
GET /api/v1/{domain}/rules/{name}/log?fired=true&since=2026-01-01
GET /api/v1/{domain}/rules/log?action=reject&fired=true
Query the evaluation log for a specific rule or across all rules.
Test Rule (Dry Run)
POST /api/v1/{domain}/rules/{name}/test
{ "record": { "personnelId": "p-1", "committedPercent": 60 } }
Evaluates the rule against a test record without writing anything. Returns whether the rule would fire and the interpolated message. Admin-only.
Enable / Disable
POST /api/v1/{domain}/rules/{name}/enable
POST /api/v1/{domain}/rules/{name}/disable
Toggle a rule at runtime without redeploying the domain. The state is persisted and survives restarts.
Statistics
GET /api/v1/{domain}/rules/stats
Returns total rules, enabled count, breakdown by action, last-30-day evaluation and fire counts, and top-firing rules.
22.11 Performance Mitigations
Cross-resource queries can be expensive. The rules engine uses several mechanisms:
Trigger pre-filtering. trigger.condition skips the cross-resource condition when the record does not apply.
Field-scoped triggers. fields prevents re-evaluation on irrelevant field changes.
Query caching. Within a single operation’s rule evaluation cycle, identical aggregation queries execute once. Three rules querying sum(Expenditure, 'amount', ...) with the same filter share one result.
Evaluation ordering. Reject rules evaluate first. If a reject fires, warn and log rules are skipped.
Rule dependencies. dependsOn skips redundant evaluations.
Index awareness. Fields used in rule conditions SHOULD be indexed (§19).
22.12 Rule Groups
Related rules can be grouped for bulk operations:
rules:
_groups:
federalCompliance:
description: "Rules enforcing 2 CFR Part 200 requirements"
rules: [effortCapEnforcement, procurementThresholdWarning, subawardBudgetConsistency]
enabled: true
exportControl:
description: "ITAR and EAR compliance rules"
rules: [itarPersonnelVerification, conflictOfInterestCheck]
enabled: true
Group API
POST /api/v1/{domain}/rules/groups/{groupName}/enable
POST /api/v1/{domain}/rules/groups/{groupName}/disable
Bulk enable or disable all rules in a group. Useful for environment-specific rule sets.
End of Chapter 22.
Next: Chapter 23 — Declarative Notifications