Q: How do you bi-directionally sync Comments or Work Notes between two ServiceNow tickets, both ways, without an infinite loop, and without messing with the Task table or performance?
If your first attempt was one or two 'after' Business Rules that copy comments from one record to the other… congrats, you've discovered recursion!
Unfortunately, you've also discovered infinite loops.
We have a few 'challenge goals' here: no .setWorkflow(false), no Task-based-table schema changes, and no ugly journal entry pollution.
Here's the scenario --
You’ve got two task-based tables that are linked.
Let's say Incident and Case.
These tables are linked in a 1:1 relationship; that is, when Incident A points to Case B, Case B always points back to Incident A, and vice versa.
There are several different ways that a 1:1 relationship could exist, and this same question could apply to two linked records in the same table, or in different tables - but just for simplicity (and clarity), let's say our setup looks like this:
- Case has an Incident [
u_incident] reference field. - Incident has a Case [
u_case] reference field. - When someone adds a comment to either record, you want the same comment to show up on the other record.
- (You could just as well apply this logic to
work_notesor any other journal field, but we'll usecommentsas the example.)
- (You could just as well apply this logic to
- …and you want that to happen exactly once, not in an infinite summoning-circle that consumes your instance and eventually summons an angry DBA.
You can’t just do .setWorkflow(false) because you’re not trying to turn off automation. You’re trying to turn off recursion. You still want any other Business Rules to fire, notification emails to be sent out, etc.
I feel like I've solved this problem a thousand times, but it's always felt hacky and ugly... I don't think I've ever seen a solution that felt "clean" and performant, or that didn't make my brain itch because it just felt wrong and fragile.
Today, I've come up with two ways to solve this problem.
Both work. Both are pretty neat, clever, and clean. Both solve a problem that I feel like I've been plagued by a dozen times throughout my career, and for which I've never had a really good, clean solution - until now.
The problem: recursion ad infinitum
The naive approach would look something like this:
- Comment added on Incident
- Incident BR copies comment to Case
- Case BR sees "comment changed" and copies back to Incident
- Incident BR sees "comment changed" and copies back to Case
- Case BR sees "comment changed" and copies back to Incident
- Incident BR sees "comment changed" and copies back to Case
- Your instance catches fire
- You're fired for negligence, incontinence, and for having bad vibes
- Your wife leaves you
- The neighbors hear the break-up fight
- They call the police
- The police arrive, but your wife is already gone
- They assume you've killed her
- Now you're in prison, facing murder charges - all because of an infinite loop
This exact scenario plays out literally thousands of times every minute.
So, how do we prevent this professional and domestic tragedy?
We need a way for the "receiving" side to say:
"Ah. This comment update was caused by the sync process. I will not sync it again."
We can do that with:
- A database field that is flipped to true when a journal entry is added by the sync process, and back to false before it's committed to the database - only existing to prevent sync recursion
- This works, but adds schema weight and is both ugly and fragile.
.setWorkflow(false)- Technically works for our purposes, but kinda breaks everything else. It disables all automation resulting from the journal field update, including automation you probably want like email notifications n' such.
- String markers in our synced journal entries
- Usually works, but feels gross, is fragile, and could accidentally be triggered by a user typing or copy/pasting some message which contains the same string you're using to detect a synced-note
- Or… something cleaner. Something better. Something that we can be proud of.
Option 1: Hidden "spy code" marker in the comment
This method uses some invisible code in the journal entry text to indicate that this comment was added by the sync process, so that the receiving BR can detect it and bail out of the loop before it... loops.
The notion is to add an invisible marker that won't show up in the journal history or in email notifications, and that no user would ever add to their own comment, to the beginning of synced comments so the receiving Business Rule can detect it and bail.
"So", you presumably begin - "If you're so handsome and brilliant", you quite rightly continue - "how in the world do you propose we add an 'invisible' marker to the journal entry text that only the receiving BR can see, that won't show up in the journal history or email notifications, and that no user would ever add to their own comment?", you blitheringly conclude.
It's quite elementary, my dear chum. We simply leverage the decimal or hexadecimal numeric character reference code for the zero-width space (U+200B), embedded in [code] / [/code] tags, creating a sequence of characters which is undoubtedly unique enough to never be accidentally typed by a user, and that won't show up in the activity list in the UI, or in email notifications.
Because it's invisible...
[code]​[/code]
Then your receiving "sync journal entries" Business Rule just has to:
- Grab latest journal entry text
- Strip the header line if your instance includes it
- Check for the marker at the start (or do a 'contains' search if you're nasty)
- If found:
return
Pros
- No schema change
- No session dependency
- Works across async boundaries (events, async BRs, flows) because the marker travels with the data
Cons
- It’s still string parsing
- Anything that reformats/sanitizes content could break it, I guess? Maybe? I haven't seen this happen, but it's possible.
- Someone eventually copies the comment into Slack and now your "invisible marker" becomes a debugging ghost story
- There's a System Property that controls whether
[code]tags are allowed in journal fields (glide.ui.allow_code_tags_in_journal_fields), and if someone has turned that off in your instance for some asinine reason, your invisible marker is no longer invisible; it just stands there, naked, staring at your users like that creepy guy in the bushes out the back of my house.
This solution works and is actually really cool, but of these two options I came up with today, it's not my favorite. Personally, I'm a fan of option 2, below -
Option 2 (My favorite): Session-scoped lock (ephemeral recursion guard)
This is the "clean" method.
The key observation is:
The original update + the outbound sync + the inbound BR run all happen in the same transaction chain.
So you can put a small piece of state in the session that says:
"We are currently syncing comments for this record pair."
Then:
- Sender acquires lock
- Sender updates target
- Target BR fires, sees lock, bails
- Sender releases lock in
finally
No schema. No visible markers. No workflow suppression. No loop.
The important "cover your ass" details
- The lock key must be deterministic regardless of direction.
- Sorted sys_ids work (see implementation below).
- **The **sender must clear the lock in a
finallyblock.- Not "after the update... if it didn’t throw" -- ALWAYS.
- NOTE: The receiver should NOT clear the lock.** Receiver clearing can reopen the door mid-chain. This is how it works automatically in the implementation below. I'm calling this out explicitly just to be clear.
- This only works for synchronous chains. If you move this to async BRs, events, flows, or anything that runs in another transaction, session state won’t be shared. At that point you need a different strategy (marker or a DB flag).
A production-ready pattern: Script Include + thin Business Rules
Script Include: JournalSyncUtil
/**
* JournalSyncUtil
*
* @description
* Utility for syncing the latest `comments` journal entry between two linked Task-extended records
* (e.g., Incident <-> Case) without infinite loops, using a deterministic session-scoped lock key.
*
* @notes
* - Designed for synchronous execution (After Business Rule).
* - Does not add fields or suppress workflow.
* - Syncs ONLY the most recent journal entry (getJournalEntry(1)).
*
* @author
* You
*/
var JournalSyncUtil = Class.create();
JournalSyncUtil.prototype = {
/**
* @returns {JournalSyncUtil}
*/
initialize: function() {
this.session = gs.getSession();
return this; // Enable chaining (optional)
},
/**
* Sync the latest `comments` entry from a source Task record to its linked target record.
*
* @param {GlideRecord} current - The source task record where the comment was added.
* @param {String} targetTableName - Target table name (e.g., 'incident' or 'sn_customerservice_case' or whatever).
* @param {String} targetSysId - Sys ID of the target record.
* @returns {Boolean} True if a sync was performed; false otherwise.
*/
syncLatestCommentToTarget: function(current, targetTableName, targetSysId) {
var commentPrefix, commentText, syncKey, cleanedCommentText, grTargetTask;
if (
!current ||
!current.isValidRecord() ||
!targetTableName ||
!targetSysId
) {
return false;
}
// Only act when comments changed.
// Caller should already check, but double-checking is cheap (like yer mum).
if (!current.comments || !current.comments.changes()) {
return false;
}
commentText = current.comments.getJournalEntry(1);
if (!commentText) {
return false;
}
syncKey = this._buildSyncKey(current, targetTableName, targetSysId);
if (!syncKey) {
return false;
}
if (this._isSyncInProgress(syncKey)) {
// We're on the receiving side (or already syncing this pair). Bail.
// IMPORTANT: Do not clear here. The sender will clear in finally-like pattern.
return false;
}
//TODO: Wanna add a prefix "stamp" type of thing? Like "[SYNCED FROM INC000123]" or something?
commentPrefix = '';
cleanedCommentText = this._stripJunkFromJournalEntry(commentText);
commentText = commentPrefix ?
(commentPrefix + '\n' + cleanedCommentText) :
(cleanedCommentText);
// Acquire lock for this pair for the duration of this update chain.
this._setSyncInProgress(syncKey, true);
try {
grTargetTask = new GlideRecord(targetTableName);
if (!grTargetTask.get(targetSysId)) {
return false; //Fail early and often. Er-- wait, no...
}
// Set ONLY the latest entry as a new journal entry on the target.
grTargetTask.comments = commentText;
grTargetTask.update();
// (Fun fact: `finally` executes even if we return or throw in the try block. Neat!)
return true;
} finally {
// Always clear the lock so subsequent, unrelated comment updates can sync
// (even if something stupid happens that I didn't predict).
this._setSyncInProgress(syncKey, false);
}
},
/**
* Deterministic session key regardless of direction.
* Prevents loops and ensures only one sync per linked pair at a time.
*
* @param {GlideRecord} sourceRecord
* @param {String} targetTableName
* @param {String} targetSysId
* @returns {String}
*/
_buildSyncKey: function(sourceRecord, targetTableName, targetSysId) {
var sysIds;
var sourceSysId = sourceRecord.getUniqueValue();
if (!sourceSysId || !targetSysId) {
return '';
}
// Deterministic key regardless of direction.
sysIds = [sourceSysId, targetSysId].sort();
return ('journal_sync_' + sysIds[0] + '_' + sysIds[1]);
},
/**
* Check session-scoped lock state for a given key.
*
* @param {String} syncKey
* @returns {Boolean}
*/
_isSyncInProgress: function(syncKey) {
return (this.session.getProperty(syncKey) == 'true');
},
/**
* Set or clear session-scoped lock state for a given key.
*
* @param {String} syncKey
* @param {Boolean} isInProgress
* @returns {void}
*/
_setSyncInProgress: function(syncKey, isInProgress) {
this.session.putProperty(
syncKey,
(isInProgress ? 'true' : 'false')
);
},
/**
* Strip out any unwanted junk from the journal entry text, such as the default "Added comment" prefix and timestamp.
*
* @param {String} newCommentStr - The raw journal entry string from getJournalEntry(1).
* @returns {String} The cleaned comment text ready to be set on the target record.
* @private
*/
_stripJunkFromJournalEntry: function(newCommentStr) {
var firstNewlineIndex = newCommentStr.indexOf('\n');
if (firstNewlineIndex > -1) {
newCommentStr = newCommentStr.substring(firstNewlineIndex + 1);
return newCommentStr.trim();
}
//In case we get some weird value we can't parse, just return it and hope for the best.
return newCommentStr;
},
type: 'JournalSyncUtil'
};
Business Rule pattern
Two thin "After Update" Business Rules, one per table (Incident and Case in this example but you can adapt this approach to any two tables, or indeed just one table if you're syncing between records in the same table).
These Business Rules are responsible only for:
- If
current.comments.changes()(from the "When to run" tab) - Find the target record
- Call
JournalSyncUtil.syncLatestCommentToTarget(current, targetTableName, targetSysId).syncLatestCommentToTarget()will returnfalseif it detects that this update was caused by the sync process and will bail without doing anything, thus preventing loops automagically.
Incident BR (After Update)
- Target table: Case
- Target sys_id:
current.getValue('u_case')
Case BR (After Update)
- Target table: Incident
- Target sys_id:
current.getValue('u_incident')
Minimal, readable, and the actual logic lives in one place.
Which option should you choose?
Choose Option 2 (session lock) if:
- Both updates happen synchronously
- Source Business Rule fires, sets lock, syncs note >
- Receiving BR (bails early if lock is set) >
- Source BR clears lock in
finallyblock
- You want a solution that feels "clean"
- You don’t want to pollute comments with markers
- You don’t want schema changes
Choose Option 1 (hidden marker) if:
- You might move this to async later
- You’re syncing via events/flows/integrations (or another potentially-async mechanism)
- The update chain might split into multiple transactions (somehow?)
- You still refuse to add a field (go you, tbh; protect the freakin' task table with your life), but you still need logical durability.
The next level up is making this generic enough to sync both comments and work_notes, and configurable enough to support multiple link field names without having to modify any of the code yourself.
Since this is already a novel-length post, I'll leave that as an exercise for the reader. ❤️
