Skip to main content

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_authority evaluator rule has no evidence to work with for BRs/SIs
  • The platform's unproven_execution rule 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:

TierEvidenceSourceConfidenceCurrent status
0Script contains REST call to Azure (static analysis)Script source code parseSTRUCTURAL✅ Done
1Recent incidents exist that would trigger this BRget_recent_trigger_examples() → trigger tableTEMPORAL_INFERREDImplemented (2026-02-20)
1.5Syslog entry links SI execution to a specific INC numbersys_loggs.error('[AzureGraphRouter] START incident=INC...')DETERMINISTICImplemented (2026-02-20)
2BR actually executed on a specific incidentsys_audit — records which BRs modified which recordsDETERMINISTIC❌ Not implemented
3That BR execution made an outbound REST callsys_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() — creates execution_evidence node with evidence_type: "trigger_record", confidence: "TEMPORAL_INFERRED", proof_notes disclosure
  • Updated _process_execution_chain() — after each BR node is created, iterates chain.trigger_examples and calls _add_trigger_record_node(br_node_id, example) for each one
  • Added _sn_trigger_record_count state counter and _br_latest_trigger_ts dict
  • Updated _finalize_workload_nodes() — BR/SI branch now sets last_observed_execution_timestamp from most recent trigger example (was always None)
  • Updated _build_evidence_completeness() — includes trigger_record_evidence source with confidence label
  • Added "trigger_record" to sv0-platform/src/domain/evidence/types.ts EVIDENCE_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) or 2 (info)
  • source: Script (for Script Include execution)
  • message: [AzureGraphRouter] START incident=INC0010020
  • sys_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:

  1. get_rest_messages() finds REST Message: Graph - sn-ticket-router (the OAuth-backed outbound call)
  2. find_callers_of_rest_message('Graph - sn-ticket-router') queries sys_script_include with scriptLIKE=Graph - sn-ticket-router — matches AzureGraphRouter because its source contains RESTMessageV2('Graph - sn-ticket-router', 'GetUserByUPN')
  3. analyze_script_mutations(si.script) detects inc.update() on the incident table — records that the SI modifies incident records (evidence that sys_audit will have rows)
  4. get_recent_trigger_examples(table='incident') fetches the 5 most recent INC records — now emitted as Tier 1 TEMPORAL_INFERRED evidence 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_log table read access (typically admin or a custom ACL — not always itil)
  • If not accessible: surface as unavailable_insufficient_permissions in 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:

FieldValueNotes
source_tableincidentWhich table the record is in
document_key<sys_id of INC>The exact record that was modified
tablenameincidentRedundant but explicit
fieldnameassigned_toWhich field changed
newvaluejohn.smithNew value after BR ran
created_on2026-02-02 10:32:01Exact execution timestamp
set_byScript or BR nameWho/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_audit table read access (typically included with itil or admin role)
  • If not accessible: surface as unavailable_insufficient_permissions in 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 = elevated on REST Message {name} to achieve full chain-of-custody proof"

Implementation Plan

Phase 1 Tasks (emit trigger records — 1 day) — ✅ DONE 2026-02-20

TaskFileStatus
Add _add_trigger_record_node() to transformertransformer.py✅ Done
Update BR/SI branch to call it + set last_observed_execution_timestamptransformer.py✅ Done
Add _sn_trigger_record_count + _br_latest_trigger_ts trackingtransformer.py✅ Done
Update _build_evidence_completeness()transformer.py✅ Done
Add "trigger_record" to EVIDENCE_TYPESsv0-platform/src/domain/evidence/types.ts✅ Done
Add tests for trigger record node emissiontests/unit/test_transformer.py⏳ TODO

Phase 1.5 Tasks (syslog — 1 day) — ✅ DONE 2026-02-20

TaskFileStatus
Add get_syslog_evidence() to SN clientservicenow_client.py✅ Done
Add syslog_records to ExecutionChaincorrelator.py✅ Done
Add _add_syslog_evidence_node() to transformertransformer.py✅ Done
Add INC number regex extractorservicenow_client.py✅ Done
Update evidence_completeness for sys_logtransformer.py✅ Done
Add confidence + proof_notes to ExecutionEvidenceDocsv0-platform/src/domain/evidence/types.ts✅ Done
Read confidence/proof_notes from node properties in transformersv0-platform/src/ingestion/graph-transformer.ts✅ Done
Surface confidence badge in UI evidence tablesv0-platform/ui/src/pages/ExposureDetailPage.tsx✅ Done
Add tests for syslog evidencetests/unit/⏳ TODO

Phase 2 Tasks (sys_audit — 3-4 days)

TaskFileNotes
Add get_br_audit_records() to SN clientservicenow_client.pyQuery sys_audit by trigger table + time window
Add br_audit_records to ExecutionChain dataclasscorrelator.pyNew field
Add discover_br_audit_evidence() to discovererdiscoverer.pyWire up the client call
Add _add_br_audit_node() to transformertransformer.pyDETERMINISTIC evidence nodes
Update evidence_completeness for sys_audittransformer.pyNew source entry
Add tests for sys_audit evidencetests/unit/

Phase 3 Tasks (outbound log — 2 days, conditional)

TaskFileNotes
Add get_outbound_http_evidence() to SN clientservicenow_client.pyQuery sys_outbound_http_log
Add timestamp correlation to discovererdiscoverer.pyMatch sys_audit ↔ outbound log within 30s window
Emit correlated chain evidence nodestransformer.pyCombined DETERMINISTIC node
Advisor message when logging not enabledtransformer.pyIn evidence_completeness.notes

What the Platform Gains

After Phase 1:

  • dormant_authority rule 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_authority rule has DETERMINISTIC proof for any BR that modified records in the audit window
  • Evidence packs show the exact audit record with timestamp
  • unproven_execution rule 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.