Business Rules Development
SkillDev toolsDevelop secure, efficient business rules with correct timing, conditions, Glide API patterns, error handling, and tests.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Business Rules Development skill
What this skill tells your AI
The instructions your AI receives, as published by happy-technologies-llc/happy-platform-skills in skills/development/business-rules/SKILL.md and read by ahel’s review.
Overview
Business rules are server-side scripts that execute when records are displayed, inserted, updated, deleted, or queried. They are the backbone of ServiceNow automation.
- What problem does it solve? Automates record-level logic, enforces data integrity, and triggers workflows based on record changes
- Who should use this skill? ServiceNow developers building custom automation logic
- Expected outcomes: Well-structured, performant business rules that follow ServiceNow best practices
Prerequisites
- Roles:
adminor scoped app developer role - Knowledge: JavaScript fundamentals, GlideRecord API basics
- Access: sys_script table, target table for business rule
- Related skills:
admin/script-execution,admin/update-set-management
When to Use Business Rules
Timing Matrix
| When | Trigger | Use Case | current/previous |
|---|---|---|---|
| before | Insert/Update/Delete | Validate data, set field values, abort operations | Both available |
| after | Insert/Update/Delete | Create related records, send notifications, external integrations | Both available |
| async | Insert/Update/Delete | Long-running operations, external API calls | Only current |
| display | Query/Display | Calculate runtime values, populate scratchpad | Only current |
Decision Guide
Use BEFORE when:
- Setting default values based on other fields
- Validating data before save
- Modifying field values before commit
- Aborting invalid operations with
current.setAbortAction(true)
Use AFTER when:
- Creating child/related records
- Sending notifications (after record is committed)
- Updating other tables
- Triggering workflows
Use ASYNC when:
- Making external REST/SOAP calls
- Processing large data sets
- Operations that can fail without blocking the user
- Long-running calculations
Use DISPLAY when:
- Calculating values for form display only
- Populating g_scratchpad for client scripts
- Runtime-only field values (not stored)
Procedure
Phase 1: Create a Business Rule
Step 1.1: Query Table Schema
First, understand the target table structure.
Using MCP:
Tool: SN-Get-Table-Schema
Parameters:
table_name: incident
Step 1.2: Create Basic Business Rule
Using MCP:
Tool: SN-Create-Record
Parameters:
table_name: sys_script
data:
name: Set Priority Based on Impact and Urgency
collection: incident
active: true
when: before
order: 100
filter_condition: impactCHANGES^ORurgencyCHANGES
script: |
(function executeRule(current, previous /*null when async*/) {
// Calculate priority from impact and urgency matrix
var impact = parseInt(current.impact);
var urgency = parseInt(current.urgency);
// Priority matrix: 1=Critical, 2=High, 3=Moderate, 4=Low, 5=Planning
var matrix = {
'1-1': 1, '1-2': 2, '1-3': 3,
'2-1': 2, '2-2': 3, '2-3': 4,
'3-1': 3, '3-2': 4, '3-3': 5
};
var key = impact + '-' + urgency;
var newPriority = matrix[key] || 4;
if (current.priority != newPriority) {
current.priority = newPriority;
gs.info('Priority calculated: ' + newPriority + ' for ' + current.number);
}
})(current, previous);
Step 1.3: Create After Business Rule
Using MCP:
Tool: SN-Create-Record
Parameters:
table_name: sys_script
data:
name: Create Related Task on P1 Incident
collection: incident
active: true
when: after
order: 200
filter_condition: priority=1^stateVALCHANGES1
script: |
(function executeRule(current, previous /*null when async*/) {
// Only on insert or when becoming P1
if (current.operation() == 'insert' ||
(previous && previous.priority != 1)) {
var task = new GlideRecord('sc_task');
task.initialize();
task.short_description = 'P1 Response: ' + current.short_description;
task.description = 'Critical incident requires immediate response.\n\nIncident: ' + current.number;
task.assignment_group = current.assignment_group;
task.assigned_to = current.assigned_to;
task.priority = 1;
task.parent = current.sys_id;
var taskId = task.insert();
gs.info('Created P1 response task: ' + taskId + ' for ' + current.number);
}
})(current, previous);
Step 1.4: Create Async Business Rule
Using MCP:
Tool: SN-Create-Record
Parameters:
table_name: sys_script
data:
name: Notify External System on Incident Create
collection: incident
active: true
when: async
order: 500
action_insert: true
script: |
(function executeRule(current, previous /*null when async*/) {
try {
var request = new sn_ws.RESTMessageV2('External Notification', 'POST');
request.setStringParameterNoEscape('incident_number', current.number.toString());
request.setStringParameterNoEscape('short_description', current.short_description.toString());
request.setStringParameterNoEscape('priority', current.priority.toString());
var response = request.execute();
var httpStatus = response.getStatusCode();
if (httpStatus == 200 || httpStatus == 201) {
gs.info('External notification sent for: ' + current.number);
} else {
gs.error('External notification failed: ' + httpStatus + ' - ' + response.getBody());
}
} catch (e) {
gs.error('External notification error: ' + e.message);
}
})(current, previous);
Phase 2: Using current and previous Objects
Step 2.1: Understanding current vs previous
The current object represents the record being processed. The previous object contains field values before the current transaction.
Key Differences:
| Aspect | current | previous |
|---|---|---|
| Availability | All business rules | before/after only (null in async) |
| Modifiable | Yes (before rules) | No (read-only) |
| Insert operations | Has values | null |
| Values | New/modified | Original before change |
Step 2.2: Detecting Field Changes
Using .changes() Method (Recommended):
Tool: SN-Create-Record
Parameters:
table_name: sys_script
data:
name: Log State Changes
collection: incident
active: true
when: after
order: 100
script: |
(function executeRule(current, previous /*null when async*/) {
// EFFICIENT: Exit early if field hasn't changed
if (!current.state.changes()) {
return; // No work to do
}
// Get old and new values
var oldState = previous ? previous.state.getDisplayValue() : '(new)';
var newState = current.state.getDisplayValue();
gs.info('Incident ' + current.number + ' state changed: ' + oldState + ' -> ' + newState);
// Add work note
current.work_notes = 'State changed from ' + oldState + ' to ' + newState;
})(current, previous);
Manual Change Detection (When .changes() Not Suitable):
// For complex comparisons or calculated changes
if (previous && current.priority != previous.priority) {
// Priority changed
}
// For reference fields, compare sys_id
if (previous && current.assigned_to.toString() != previous.assigned_to.toString()) {
// Assignment changed
}
// For multiple fields
var fieldsToCheck = ['state', 'priority', 'assigned_to'];
var changedFields = [];
for (var i = 0; i < fieldsToCheck.length; i++) {
var field = fieldsToCheck[i];
if (current[field].changes()) {
changedFields.push(field);
}
}
if (changedFields.length > 0) {
gs.info('Changed fields: ' + changedFields.join(', '));
}
Step 2.3: Checking Operation Type
// Determine what triggered the business rule
var operation = current.operation();
switch (operation) {
case 'insert':
// Record is being created
gs.info('New record: ' + current.getTableName());
break;
case 'update':
// Record is being updated
gs.info('Update to: ' + current.getUniqueValue());
break;
case 'delete':
// Record is being deleted
gs.info('Deleting: ' + current.getUniqueValue());
break;
}
Phase 3: Condition Field vs Script Conditions
Step 3.1: Condition Field (Preferred for Simple Conditions)
The condition field uses encoded queries and is evaluated BEFORE the script runs. This is more efficient because:
- Evaluated at the database level
- Script never executes if condition fails
- No JavaScript overhead
Best Practices for Condition Field:
# Only run on active P1 incidents
active=true^priority=1
# Only when state changes to Resolved
stateVALCHANGES6
# Only when assigned_to changes
assigned_toCHANGES
# Multiple conditions (AND)
active=true^priority=1^stateVALCHANGES6
# Only on insert (no previous value for state)
stateISEMPTYfalse^ORstateISEMPTY
Common Condition Operators:
| Operator | Meaning | Example |
|---|---|---|
| CHANGES | Field value changed | stateCHANGES |
| VALCHANGES | Changed TO specific value | stateVALCHANGES6 |
| CHANGESFROM | Changed FROM specific value | stateCHANGESFROM1 |
| = | Equals | priority=1 |
| != | Not equals | state!=7 |
| ISEMPTY | Field is empty | assigned_toISEMPTY |
| ISNOTEMPTY | Field has value | assigned_toISNOTEMPTY |
Step 3.2: Script Conditions (For Complex Logic)
Use script conditions only when the condition field cannot express the logic.
Using MCP:
Tool: SN-Create-Record
Parameters:
table_name: sys_script
data:
name: Complex Condition Example
collection: incident
active: true
when: before
order: 100
condition: |
// Script condition - returns true/false
// Available: current, previous
// Check if escalating (priority going from higher number to lower)
if (current.priority.changes()) {
var oldPri = previous ? parseInt(previous.priority) : 5;
var newPri = parseInt(current.priority);
return newPri < oldPri; // True if escalating
}
return false;
script: |
(function executeRule(current, previous /*null when async*/) {
// This only runs if condition returned true
current.work_notes = 'Incident escalated from P' + previous.priority + ' to P' + current.priority;
gs.info('Escalation detected: ' + current.number);
})(current, previous);
When to Use Script Conditions:
- Complex date calculations
- Cross-table validations
- Dynamic conditions based on user roles
- Calculations involving multiple fields
- Conditions requiring GlideRecord queries
Phase 4: Common Glide API Methods
Step 4.1: GlideRecord Essentials
// Query records
var gr = new GlideRecord('incident');
gr.addQuery('active', true);
gr.addQuery('priority', 1);
gr.orderByDesc('sys_created_on');
gr.setLimit(10);
gr.query();
while (gr.next()) {
gs.info('Found: ' + gr.number + ' - ' + gr.short_description);
}
// Get single record by sys_id
var incident = new GlideRecord('incident');
if (incident.get('sys_id_value')) {
gs.info('Found: ' + incident.number);
}
// Get by field value
var user = new GlideRecord('sys_user');
if (user.get('user_name', 'admin')) {
gs.info('Admin sys_id: ' + user.sys_id);
}
// Insert new record
var newTask = new GlideRecord('task');
newTask.initialize();
newTask.short_description = 'New task from business rule';
newTask.assignment_group = current.assignment_group;
var sysId = newTask.insert();
// Update record
var toUpdate = new GlideRecord('incident');
if (toUpdate.get('sys_id_value')) {
toUpdate.work_notes = 'Updated via business rule';
toUpdate.update();
}
// Delete record (use with caution)
var toDelete = new GlideRecord('task');
if (toDelete.get('sys_id_value')) {
toDelete.deleteRecord();
}
Step 4.2: GlideSystem (gs) Methods
// Logging
gs.info('Information message');
gs.warn('Warning message');
gs.error('Error message');
gs.debug('Debug message'); // Requires debug enabled
// User context
var userId = gs.getUserID();
var userName = gs.getUserName();
var userDisplayName = gs.getUserDisplayName();
// Check roles
if (gs.hasRole('admin')) {
// Admin-only logic
}
if (gs.hasRole('itil') || gs.hasRole('catalog_admin')) {
// ITIL or catalog admin logic
}
// Date/Time
var now = gs.now(); // Current date/time string
var nowDT = gs.nowDateTime(); // GlideDateTime
var today = gs.beginningOfToday(); // Start of today
var daysAgo = gs.daysAgo(7); // 7 days ago
// Properties
var propValue = gs.getProperty('my.property.name', 'default');
gs.setProperty('my.property.name', 'new_value');
// Generate GUID
var guid = gs.generateGUID();
// Include script include
gs.include('MyScriptInclude');
var util = new MyScriptInclude();
// Nil check (empty string, null, undefined)
if (gs.nil(current.assigned_to)) {
// Field is empty
}
// Event queue (trigger events)
gs.eventQueue('incident.created', current, current.number, current.priority);
Step 4.3: GlideDateTime Operations
// Current time
var now = new GlideDateTime();
// Create from string
var dt = new GlideDateTime('2026-02-06 10:30:00');
// Add/subtract time
var future = new GlideDateTime();
future.addDays(5);
future.addHours(2);
future.addMinutes(30);
// Compare dates
var dt1 = new GlideDateTime();
var dt2 = new GlideDateTime(current.sys_created_on);
if (dt1.after(dt2)) {
gs.info('dt1 is after dt2');
}
// Calculate duration
var duration = GlideDateTime.subtract(dt2, dt1); // Returns GlideDuration
var seconds = duration.getNumericValue() / 1000;
var displayValue = duration.getDisplayValue(); // "2 Days 3 Hours"
// Business time calculations
var schedule = new GlideSchedule('sys_id_of_schedule');
var dur = schedule.duration(dt1, dt2);
// Format output
var formatted = now.getDisplayValue(); // User's timezone
var internal = now.getValue(); // Internal format (UTC)
var date = now.getLocalDate().getValue(); // Date only
Step 4.4: GlideAggregate for Counts and Sums
// Count records
var ga = new GlideAggregate('incident');
ga.addQuery('active', true);
ga.addAggregate('COUNT');
ga.query();
if (ga.next()) {
var count = ga.getAggregate('COUNT');
gs.info('Active incidents: ' + count);
}
// Count by group
var ga = new GlideAggregate('incident');
ga.addQuery('active', true);
ga.addAggregate('COUNT');
ga.groupBy('priority');
ga.orderByAggregate('COUNT', false); // Descending
ga.query();
while (ga.next()) {
var priority = ga.priority.getDisplayValue();
var count = ga.getAggregate('COUNT');
gs.info(priority + ': ' + count + ' incidents');
}
// Sum values
var ga = new GlideAggregate('sc_task');
ga.addQuery('active', true);
ga.addAggregate('SUM', 'time_worked');
ga.query();
if (ga.next()) {
var totalTime = ga.getAggregate('SUM', 'time_worked');
}
// Average
ga.addAggregate('AVG', 'priority');
// Min/Max
ga.addAggregate('MIN', 'sys_created_on');
ga.addAggregate('MAX', 'sys_created_on');
Phase 5: Error Handling and Logging
Step 5.1: Comprehensive Error Handling Pattern
Using MCP:
Tool: SN-Create-Record
Parameters:
table_name: sys_script
data:
name: Robust Error Handling Example
collection: incident
active: true
when: after
order: 100
script: |
(function executeRule(current, previous /*null when async*/) {
var BR_NAME = 'Robust Error Handling Example';
try {
// Validate inputs
if (gs.nil(current.sys_id)) {
throw new Error('Current record has no sys_id');
}
// Main logic
var relatedCI = new GlideRecord('cmdb_ci');
if (!relatedCI.get(current.cmdb_ci)) {
gs.warn('[' + BR_NAME + '] No CI found for incident: ' + current.number);
return; // Exit gracefully
}
// Perform operations
relatedCI.operational_status = 2; // Under maintenance
relatedCI.update();
gs.info('[' + BR_NAME + '] Updated CI: ' + relatedCI.name);
} catch (e) {
// Log error with context
gs.error('[' + BR_NAME + '] Error: ' + e.message);
gs.error('[' + BR_NAME + '] Incident: ' + current.number);
gs.error('[' + BR_NAME + '] Stack: ' + e.stack);
// Optionally create incident for business rule error
// createErrorIncident(BR_NAME, e, current);
}
})(current, previous);
Step 5.2: Abort Actions with User Feedback
// Before business rule - prevent invalid save
(function executeRule(current, previous) {
var errors = [];
// Validation 1: Required field
if (current.priority == 1 && gs.nil(current.assigned_to)) {
errors.push('P1 incidents must have an assigned user');
}
// Validation 2: Business logic
if (current.state == 6 && gs.nil(current.resolution_notes)) {
errors.push('Resolution notes are required when resolving');
}
// Validation 3: Cross-field validation
if (current.impact == 1 && current.urgency == 1 && current.priority != 1) {
errors.push('Impact 1 + Urgency 1 must equal Priority 1');
}
// Abort if errors
if (errors.length > 0) {
var errorMsg = errors.join('\n');
gs.addErrorMessage(errorMsg);
current.setAbortAction(true);
gs.info('[Validation BR] Aborted save: ' + errorMsg);
}
})(current, previous);
Phase 6: Performance Optimization
Step 6.1: Use .changes() for Efficiency
WRONG - Always executes full script:
// Inefficient - script runs on every update
(function executeRule(current, previous) {
if (previous && current.state != previous.state) {
// State changed - do work
}
})(current, previous);
RIGHT - Use condition field + .changes():
Filter Condition: stateCHANGES
Script:
(function executeRule(current, previous) {
// Script only runs when state changes
// Additional validation with .changes() for safety
if (current.state.changes()) {
// Do work
}
})(current, previous);
Step 6.2: Minimize GlideRecord Queries
WRONG - Query inside loop:
var incidents = new GlideRecord('incident');
incidents.query();
while (incidents.next()) {
// BAD: Query for each incident
var user = new GlideRecord('sys_user');
user.get(incidents.assigned_to);
// ...
}
RIGHT - Use dot-walking or batch queries:
// Option 1: Dot-walking (single query)
var incidents = new GlideRecord('incident');
incidents.query();
while (incidents.next()) {
var userName = incidents.assigned_to.name; // Dot-walk
var userEmail = incidents.assigned_to.email;
}
// Option 2: Batch query with lookup map
var userIds = [];
var incidents = new GlideRecord('incident');
incidents.query();
while (incidents.next()) {
if (!gs.nil(incidents.assigned_to)) {
userIds.push(incidents.assigned_to.toString());
}
}
var userMap = {};
if (userIds.length > 0) {
var users = new GlideRecord('sys_user');
users.addQuery('sys_id', 'IN', userIds.join(','));
users.query();
while (users.next()) {
userMap[users.sys_id.toString()] = {
name: users.name.toString(),
email: users.email.toString()
};
}
}
Step 6.3: Order and Active Flag Best Practices
| Practice | Recommendation |
|---|---|
| Order | 100-199 for validation, 200-399 for field setting, 400+ for external/async |
| Active | Set to false during development, enable when tested |
| Condition | Use filter condition field, not script conditions |
| Inheritance | Set "Inherits" carefully - usually leave unchecked |
Step 6.4: Avoid Recursive Updates
DANGEROUS - Can cause infinite loops:
// After business rule on incident
(function executeRule(current, previous) {
current.work_notes = 'Updated at ' + gs.now();
current.update(); // DANGER: Triggers business rules again!
})(current, previous);
SAFE - Set workflow false or use before rules:
// Option 1: Disable workflow on update
var gr = new GlideRecord('incident');
if (gr.get(current.sys_id)) {
gr.setWorkflow(false); // Skip business rules
gr.autoSysFields(false); // Skip sys field updates
gr.work_notes = 'Updated at ' + gs.now();
gr.update();
}
// Option 2: Use before rule instead (preferred)
// Before business rule - modifies current directly
(function executeRule(current, previous) {
current.work_notes = 'Updated at ' + gs.now();
// No .update() needed - current is saved automatically
})(current, previous);
Phase 7: Testing Business Rules
Step 7.1: Query Existing Business Rules
Using MCP:
Tool: SN-Query-Table
Parameters:
table_name: sys_script
query: collection=incident^active=true
fields: name,when,order,filter_condition,active
limit: 50
Step 7.2: Test with Background Script
Using MCP:
Tool: SN-Execute-Background-Script
Parameters:
script: |
// Test business rule by simulating record operation
var testIncident = new GlideRecord('incident');
testIncident.initialize();
testIncident.short_description = 'Test incident for BR testing';
testIncident.caller_id = gs.getUserID();
testIncident.impact = 1;
testIncident.urgency = 1;
// Insert will trigger before/after insert rules
var sysId = testIncident.insert();
gs.info('Created test incident: ' + sysId);
// Verify priority was calculated
testIncident.get(sysId);
gs.info('Priority after BR: ' + testIncident.priority.getDisplayValue());
// Clean up (optional)
// testIncident.deleteRecord();
description: Test priority calculation business rule
Step 7.3: Check System Logs
Using MCP:
Tool: SN-Query-Table
Parameters:
table_name: syslog
query: messageLIKEbusiness rule^ORmessageLIKE[BR]^sys_created_on>javascript:gs.minutesAgo(10)
fields: message,level,sys_created_on,source
limit: 50
Step 7.4: Explicit Local Development Cycle
Pull the current script and freshness metadata:
Tool: SN-Get-Record
Parameters:
table_name: sys_script
sys_id: [business_rule_sys_id]
fields: sys_id,script,sys_updated_on,sys_mod_count
instance: dev
Save the script field locally and edit it in the IDE. Immediately before
pushing, repeat SN-Get-Record and compare sys_updated_on, sys_mod_count,
and the original script. If the remote record changed, merge it locally instead
of overwriting it. Otherwise push only the reviewed script field:
Tool: SN-Update-Record
Parameters:
table_name: sys_script
sys_id: [business_rule_sys_id]
data:
script: [reviewed_local_script]
instance: dev
Tool Usage Summary
| Operation | MCP Tool | Purpose |
|---|---|---|
| Create BR | SN-Create-Record (sys_script) | Create new business rule |
| Update BR | SN-Update-Record | Modify existing business rule |
| Query BRs | SN-Query-Table | Find business rules on table |
| Test BR | SN-Execute-Background-Script | Simulate record operations |
| Debug | SN-Query-Table (syslog) | Check execution logs |
| Local Dev | SN-Get-Record + SN-Update-Record | Explicit pull, freshness check, edit, and push |
| Get Schema | SN-Get-Table-Schema | Understand table structure |
Best Practices
Code Quality
- Use IIFE Pattern: Wrap all scripts in
(function executeRule(current, previous) {...})(current, previous); - Name Variables Clearly: Use descriptive names like
existingTasknotgr2 - Comment Complex Logic: Explain why, not what
- Avoid Magic Numbers: Use constants or comments for values like
state=6
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 37
- Forks
- 13
- Last commit
- Jul 2026
Advanced
- Catalog kind
- skill
- Gateway key
business-rules- Source
- github.com/happy-technologies-llc/happy-platform-skills