ServiceNow BR/SI Execution Evidence — Incident Linkage Plan
Date: 2026-02-20 Status: DRAFT — ready for implementation
Problem Statement
Business Rules (BRs) and Script Includes (SIs) are the most common autonomous automation type in ServiceNow — they run server-side on record insert/update/delete events. Yet they are the only automation type with zero execution evidence in the current connector.
The transformer at transformer.py:1192-1194 acknowledges this explicitly:
elif subtype in ("business_rule", "script_include"):
# No deterministic SN-side execution log for BRs/SIs
props["last_observed_execution_timestamp"] = None
This means:
- The platform's
dormant_authorityevaluator rule has no evidence to work with for BRs/SIs - The platform's
unproven_executionrule incorrectly shows no execution for active automations - Evidence packs for these automation types have empty execution sections
The lost capability
An earlier version of the connector used incident record linkage to prove execution. The chain was:
INC-XXXXXXX created (trigger event)
→ Business Rule fires on incident table insert
→ Script Include called
→ REST Message to Azure
→ Azure SP executes
Knowing a specific incident number (e.g. INC0010020) that triggered the chain was sufficient execution proof — it's a concrete, auditable, immutable record in ServiceNow.
What exists today (but isn't used)
The connector already fetches recent trigger record examples via get_recent_trigger_examples() (servicenow_client.py:897-950). For a BR on the incident table, this returns recent incident records including their number (INC-XXXXXXX), created_by, created_on, and short_description.
These records live in ExecutionChain.trigger_examples and appear in markdown reports — but are never emitted into the NormalizedGraph as evidence nodes. The platform never sees them.
Evidence Tiers
Four tiers of execution evidence are possible for BRs/SIs, from weakest to strongest:
| Tier | Evidence | Source | Confidence | Current status |
|---|---|---|---|---|
| 0 | Script contains REST call to Azure (static analysis) | Script source code parse | STRUCTURAL | ✅ Done |
| 1 | Recent incidents exist that would trigger this BR | get_recent_trigger_examples() → trigger table | TEMPORAL_INFERRED | ✅ Implemented (2026-02-20) |
| 1.5 | Syslog entry links SI execution to a specific INC number | sys_log — gs.error('[AzureGraphRouter] START incident=INC...') | DETERMINISTIC | ✅ Implemented (2026-02-20) |
| 2 | BR actually executed on a specific incident | sys_audit — records which BRs modified which records | DETERMINISTIC | ❌ Not implemented |
| 3 | That BR execution made an outbound REST call | sys_outbound_http_log (requires elevated logging) | DETERMINISTIC | ❌ Not implemented |
Phases 1 and 1.5 are now implemented. Phase 1 emits trigger_record (TEMPORAL_INFERRED) nodes. Phase 1.5 emits syslog (DETERMINISTIC) nodes for scripts that use structured logging. Both surface in the platform UI with confidence badges. Phase 2 (sys_audit) is next.
Phase 1: Emit Trigger Records into the NormalizedGraph
Effort: ~1 day
Confidence level: TEMPORAL_INFERRED
What it proves: "Recent incidents of the type this BR monitors existed — the BR likely ran."
What it does not prove: That a specific incident deterministically caused this specific execution.
What to build
Add _add_trigger_record_node() to the transformer, mirroring the existing _add_flow_execution_node() and _add_job_execution_node() pattern.
Data source: ExecutionChain.trigger_examples — already populated by get_recent_trigger_examples().
Node structure:
{
"nodeId": f"evidence-trigger-{table}-{record_sys_id}",
"nodeType": "execution_evidence",
"sourceSystem": "servicenow",
"sourceId": record_sys_id,
"displayName": f"Trigger Record: {table}/{record_number}",
"evidenceConfidence": "TEMPORAL_INFERRED",
"properties": {
"evidence_type": "trigger_record",
"trigger_table": table, # e.g. "incident"
"trigger_record_number": record_number, # e.g. "INC0010020"
"trigger_record_id": record_sys_id, # sys_id for auditability
"created_at": created_on, # when the trigger event occurred
"created_by": created_by, # who triggered it
"caller_id": caller_id, # for incident: who reported it
"short_description": short_description,
"confidence": "TEMPORAL_INFERRED",
# Explicit disclosure of what this does and does not prove
"proof_notes": (
"Recent trigger record confirms the BR's monitored table has active events. "
"Does NOT prove this specific record caused this specific BR execution. "
"See sys_audit for deterministic proof."
),
},
}
Edge:
{
"edgeType": "EVIDENCES",
"sourceNodeId": evidence_node_id,
"targetNodeId": workload_node_id, # the BR/SI workload node
}
Where to add it
File: src/entra_servicenow/core/transformer.py
Current gap (line 1192-1194):
elif subtype in ("business_rule", "script_include"):
# No deterministic SN-side execution log for BRs/SIs
props["last_observed_execution_timestamp"] = None
Replace with:
elif subtype in ("business_rule", "script_include"):
# Emit trigger record examples as TEMPORAL_INFERRED evidence
# (data already fetched by get_recent_trigger_examples)
for example in exec_record.get("trigger_examples", []):
self._add_trigger_record_node(node["nodeId"], example)
# Set last observed from most recent trigger example if available
if exec_record.get("trigger_examples"):
most_recent = max(
exec_record["trigger_examples"],
key=lambda r: r.get("sys_created_on", ""),
default=None,
)
if most_recent:
props["last_observed_execution_timestamp"] = most_recent.get("sys_created_on")
Phase 1 implementation status (2026-02-20)
Implemented. Changes landed in transformer.py:
- Added
_add_trigger_record_node()— createsexecution_evidencenode withevidence_type: "trigger_record",confidence: "TEMPORAL_INFERRED",proof_notesdisclosure - Updated
_process_execution_chain()— after each BR node is created, iterateschain.trigger_examplesand calls_add_trigger_record_node(br_node_id, example)for each one - Added
_sn_trigger_record_countstate counter and_br_latest_trigger_tsdict - Updated
_finalize_workload_nodes()— BR/SI branch now setslast_observed_execution_timestampfrom most recent trigger example (was alwaysNone) - Updated
_build_evidence_completeness()— includestrigger_record_evidencesource with confidence label - Added
"trigger_record"tosv0-platform/src/domain/evidence/types.tsEVIDENCE_TYPES
Update evidence_completeness
Add trigger_records as a source table in _build_evidence_completeness():
if self._sn_trigger_record_count > 0:
source_tables.append("trigger_records")
sources["trigger_record_evidence"] = {
"sourceTable": "incident|change_request|...",
"status": "available",
"confidence": "TEMPORAL_INFERRED",
"recordCount": self._sn_trigger_record_count,
"notes": (
"Trigger record examples confirm recent activity on the BR's monitored table. "
"Not deterministic proof of BR execution — see sys_audit gap."
),
}
Update evidence type registry
Add "trigger_record" to the platform's EVIDENCE_TYPES in sv0-platform/src/domain/evidence/types.ts (alongside the "agent_run_summary" added in a prior session).
Phase 1.5: sys_log / Syslog Integration (DETERMINISTIC for Logging Scripts)
Effort: ~1 day
Confidence level: DETERMINISTIC (when scripts use structured syslog)
What it proves: "Script Include AzureGraphRouter executed for incident INC-XXXXXXX at timestamp T."
Prerequisite: The Script Include must contain gs.error()/gs.info() calls with parseable output. Not all scripts do this — it's an opt-in pattern, not a platform guarantee.
Motivation: AzureGraphRouter
The AzureGraphRouter Script Include (a real customer-deployed SI) uses this pattern:
gs.error('[AzureGraphRouter] START incident=' + incNum);
// ... processing ...
gs.error('[AzureGraphRouter] DONE incident=' + incNum + ' group=' + grpSysId);
This creates rows in sys_log (ServiceNow's application log table) with:
level:3(error) or2(info)source:Script(for Script Include execution)message:[AzureGraphRouter] START incident=INC0010020sys_created_on: exact execution timestamp
The incident= field directly names the INC number — making this a DETERMINISTIC execution proof (the script ran, for this specific incident, at this exact time) without needing sys_audit access.
Connector targeting of AzureGraphRouter
Current status: fully targeted. The connector discovers AzureGraphRouter via the following chain:
get_rest_messages()findsREST Message: Graph - sn-ticket-router(the OAuth-backed outbound call)find_callers_of_rest_message('Graph - sn-ticket-router')queriessys_script_includewithscriptLIKE=Graph - sn-ticket-router— matchesAzureGraphRouterbecause its source containsRESTMessageV2('Graph - sn-ticket-router', 'GetUserByUPN')analyze_script_mutations(si.script)detectsinc.update()on theincidenttable — records that the SI modifies incident records (evidence that sys_audit will have rows)get_recent_trigger_examples(table='incident')fetches the 5 most recent INC records — now emitted as Tier 1TEMPORAL_INFERREDevidence nodes
What's missing: the connector does not query sys_log for [AzureGraphRouter] START incident= entries. Adding this would upgrade the evidence for logging-equipped scripts from TEMPORAL_INFERRED to DETERMINISTIC.
Query pattern
# Query sys_log for structured syslog entries from this Script Include
params = {
"sysparm_query": (
f"messageLIKE[{si_name}]"
f"^sys_created_onRELATIVEGT@hour@-720" # last 30 days
f"^source=Script"
),
"sysparm_fields": "sys_id,message,source,level,sys_created_on",
"sysparm_limit": "10",
"sysparm_orderby": "sys_created_on^DESC",
}
Incident number extraction
Parse the INC number from the log message:
import re
_RE_INC_NUMBER = re.compile(r'incident=(INC\d+)', re.IGNORECASE)
def extract_inc_from_syslog(message: str) -> str | None:
m = _RE_INC_NUMBER.search(message)
return m.group(1) if m else None
Evidence node structure (Phase 1.5)
{
"nodeId": f"evidence-syslog-{log_sys_id}",
"nodeType": "execution_evidence",
"sourceSystem": "servicenow",
"sourceId": log_sys_id,
"displayName": f"Syslog: {si_name} executed for {inc_number}",
"evidenceConfidence": "DETERMINISTIC",
"properties": {
"source_table": "sys_log",
"evidence_type": "trigger_record", # reuse type; proof_notes clarifies
"trigger_record_number": inc_number, # e.g. "INC0010020"
"executed_at": sys_created_on,
"log_message": message,
"confidence": "DETERMINISTIC",
"proof_notes": "sys_log entry directly names the incident — SI executed for this INC.",
},
}
Access requirements
sys_log requires the connector's SN user to have:
sys_logtable read access (typicallyadminor a custom ACL — not alwaysitil)- If not accessible: surface as
unavailable_insufficient_permissionsin evidence_completeness
Phase 2: sys_audit Integration (Deterministic BR Execution Proof)
Effort: ~3-4 days
Confidence level: DETERMINISTIC
What it proves: "BR X modified record INC-XXXXXXX at timestamp T — it definitely ran."
How sys_audit works
sys_audit in ServiceNow records every field-level change with:
| Field | Value | Notes |
|---|---|---|
source_table | incident | Which table the record is in |
document_key | <sys_id of INC> | The exact record that was modified |
tablename | incident | Redundant but explicit |
fieldname | assigned_to | Which field changed |
newvalue | john.smith | New value after BR ran |
created_on | 2026-02-02 10:32:01 | Exact execution timestamp |
set_by | Script or BR name | Who/what set this value |
When a Business Rule modifies a field on an incident, sys_audit has a row where set_by = "Script" (for script-based BRs) or the BR name. Combined with the incident's sys_id, this is deterministic proof the BR executed on that incident.
Query pattern
# For each BR, query sys_audit for recent modifications on its trigger table
# where the modification was made by a script (BR execution signature)
params = {
"sysparm_query": (
f"tablename={trigger_table}"
f"^created_onRELATIVEGT@hour@-720" # last 30 days
f"^set_by=Script" # script-based modification
f"^ORset_by={br_name}" # or explicit BR name
),
"sysparm_fields": "sys_id,document_key,tablename,fieldname,newvalue,created_on,set_by",
"sysparm_limit": "10",
"sysparm_orderby": "created_on^DESC",
}
New evidence node structure (Phase 2)
{
"nodeId": f"evidence-br-audit-{audit_sys_id}",
"nodeType": "execution_evidence",
"sourceSystem": "servicenow",
"sourceId": audit_sys_id,
"displayName": f"BR Execution: {br_name} modified {table}/{record_number}",
"evidenceConfidence": "DETERMINISTIC",
"properties": {
"evidence_type": "sys_audit",
"source_table": "sys_audit",
"trigger_table": trigger_table,
"trigger_record_id": document_key,
"executed_at": created_on, # exact timestamp from sys_audit
"set_by": set_by, # "Script" or BR name
"field_modified": fieldname,
"confidence": "DETERMINISTIC",
"proof_notes": "sys_audit record confirms BR modified this record at this timestamp.",
},
}
Access requirements
sys_audit requires the connector's ServiceNow user to have:
sys_audittable read access (typically included withitiloradminrole)- If not accessible: surface as
unavailable_insufficient_permissionsin evidence_completeness
Phase 3: sys_outbound_http_log Correlation (Full Chain Proof)
Effort: ~2 days (after Phase 2)
Confidence level: DETERMINISTIC (when outbound logging is elevated)
What it proves: "BR executed → made REST call to Azure endpoint → at this timestamp."
Prerequisite: Customer must enable glide.rest.outbound_log_level = elevated (SaaS constraint — cannot force)
Query pattern
# Find outbound REST calls from the REST Message used by this BR
# within a time window around the sys_audit execution timestamp
params = {
"sysparm_query": (
f"rest_message={rest_message_name}"
f"^created_onBETWEENjavascript:gs.dateGenerate('{audit_ts_minus_30s}','{audit_ts_plus_30s}')"
),
"sysparm_fields": "sys_id,rest_message,http_method,endpoint_url,http_status,created_on",
}
Correlation logic
Match a sys_audit record (BR executed at T) with a sys_outbound_http_log record (REST call at T+Δ, Δ < 30s) for the same REST Message. This closes the chain:
INC-XXXXXXX created at T-0
→ sys_audit: BR modified INC at T+1 (DETERMINISTIC)
→ sys_outbound_http_log: REST call to Azure at T+1.5 (DETERMINISTIC, if logging enabled)
→ Azure SP sign-in at T+2 (DETERMINISTIC, Entra sign-in log)
Fallback when logging is not elevated
If sys_outbound_http_log is not accessible or empty:
- Fall back to Phase 2 evidence (sys_audit only)
- Surface in evidence_completeness:
outbound_http_log: unavailable_not_enabled - Display advisor recommendation: "Enable
glide.rest.outbound_log_level = elevatedon REST Message{name}to achieve full chain-of-custody proof"
Implementation Plan
Phase 1 Tasks (emit trigger records — 1 day) — ✅ DONE 2026-02-20
| Task | File | Status |
|---|---|---|
Add _add_trigger_record_node() to transformer | transformer.py | ✅ Done |
Update BR/SI branch to call it + set last_observed_execution_timestamp | transformer.py | ✅ Done |
Add _sn_trigger_record_count + _br_latest_trigger_ts tracking | transformer.py | ✅ Done |
Update _build_evidence_completeness() | transformer.py | ✅ Done |
Add "trigger_record" to EVIDENCE_TYPES | sv0-platform/src/domain/evidence/types.ts | ✅ Done |
| Add tests for trigger record node emission | tests/unit/test_transformer.py | ⏳ TODO |
Phase 1.5 Tasks (syslog — 1 day) — ✅ DONE 2026-02-20
| Task | File | Status |
|---|---|---|
Add get_syslog_evidence() to SN client | servicenow_client.py | ✅ Done |
Add syslog_records to ExecutionChain | correlator.py | ✅ Done |
Add _add_syslog_evidence_node() to transformer | transformer.py | ✅ Done |
Add INC number regex extractor | servicenow_client.py | ✅ Done |
| Update evidence_completeness for sys_log | transformer.py | ✅ Done |
Add confidence + proof_notes to ExecutionEvidenceDoc | sv0-platform/src/domain/evidence/types.ts | ✅ Done |
Read confidence/proof_notes from node properties in transformer | sv0-platform/src/ingestion/graph-transformer.ts | ✅ Done |
| Surface confidence badge in UI evidence table | sv0-platform/ui/src/pages/ExposureDetailPage.tsx | ✅ Done |
| Add tests for syslog evidence | tests/unit/ | ⏳ TODO |
Phase 2 Tasks (sys_audit — 3-4 days)
| Task | File | Notes |
|---|---|---|
Add get_br_audit_records() to SN client | servicenow_client.py | Query sys_audit by trigger table + time window |
Add br_audit_records to ExecutionChain dataclass | correlator.py | New field |
Add discover_br_audit_evidence() to discoverer | discoverer.py | Wire up the client call |
Add _add_br_audit_node() to transformer | transformer.py | DETERMINISTIC evidence nodes |
| Update evidence_completeness for sys_audit | transformer.py | New source entry |
| Add tests for sys_audit evidence | tests/unit/ |
Phase 3 Tasks (outbound log — 2 days, conditional)
| Task | File | Notes |
|---|---|---|
Add get_outbound_http_evidence() to SN client | servicenow_client.py | Query sys_outbound_http_log |
| Add timestamp correlation to discoverer | discoverer.py | Match sys_audit ↔ outbound log within 30s window |
| Emit correlated chain evidence nodes | transformer.py | Combined DETERMINISTIC node |
| Advisor message when logging not enabled | transformer.py | In evidence_completeness.notes |
What the Platform Gains
After Phase 1:
dormant_authorityrule has evidence for BRs/SIs — recent trigger records prove the automation context is active- Evidence packs show "Trigger Record: incident/INC0010020 (2026-02-02)" instead of empty execution section
- Evidence confidence shown as
TEMPORAL_INFERRED— honest about what is and isn't proven
After Phase 2:
dormant_authorityrule hasDETERMINISTICproof for any BR that modified records in the audit window- Evidence packs show the exact audit record with timestamp
unproven_executionrule can be suppressed with confidence for active BRs
After Phase 3:
- Full chain-of-custody: INC → BR → REST → SP — every hop has a log entry
- Evidence pack section 2 (
cross_system_auth) can show the complete chain - The platform's "deterministic" design constraint is actually met for this automation type
Execution Evidence Model (Final Target)
incident/INC0010020 (created 2026-02-02T10:32:00Z)
─── TRIGGERS ──► business_rule/ticket_router
[sys_audit: BR modified INC at 10:32:01Z — DETERMINISTIC]
─── EVIDENCES ──► workload(sn-br-ticket_router)
─── CALLS ──► REST Message/EntraIntegration
[sys_outbound_http_log: POST to graph.microsoft.com at 10:32:01.5Z — DETERMINISTIC]
─── CALLS ──► identity(entra-sp-abc-123)
[Entra sign-in: abc-123 authenticated at 10:32:02Z — DETERMINISTIC]
This chain satisfies: Trigger → Identity/Credential → Authority → Human provenance.