Developer Analysis: Execution Flow Provenance Enhancement
Date: 2026-02-13 Author: Developer (Claude Code) Status: Technical specification Scope: Code changes needed to show full execution provenance path (BR → SI → REST → SP → Resources)
Executive Summary
This document specifies the exact code changes needed across the stack (connector, platform API, platform UI) to display the complete execution provenance chain for ServiceNow automations. Currently, the UI shows only Script Includes (system_execution) when viewing automation graphs, missing the Business Rules (entry points) that invoke them. The CALLS relationship exists in the correlator but is not emitted in the NormalizedGraph or processed by the platform.
Core Problem: When viewing AzureGraphRouter (Script Include), the UI shows:
- TRIGGER: "Unknown" (SIs don't have triggers — the BRs do)
- No Business Rules in the graph
- No BR→SI invocation link
- No trigger incident records
- Generic "identity" label instead of "automation"
Solution: Add CALLS edge type to the full stack, update UI to handle multi-hop execution chains, and display trigger context and mutation effects.
1. UI Label Fix: "automation" vs "identity"
Problem
EntityBadge.tsx shows entity.entity_type → always renders "identity" for all autonomous_identity nodes (BR, Flow, Job, SI, SP, OAuth).
Solution: Derive Display Label from Properties
Option A (Recommended): UI-side label derivation — Keep entity_type: "identity" in database, derive display label in UI.
// ui/src/components/shared/EntityBadge.tsx
import type { EntityType } from "../../api/api-types.ts";
const ENTITY_BADGE_COLORS: Record<string, string> = {
automation: "bg-indigo-100 text-indigo-800", // NEW
identity: "bg-blue-100 text-blue-800",
owner: "bg-green-100 text-green-800",
role: "bg-purple-100 text-purple-800",
permission: "bg-orange-100 text-orange-800",
resource: "bg-red-100 text-red-800",
credential: "bg-gray-100 text-gray-700",
};
const AUTOMATION_SUBTYPES = new Set([
"business_rule", "flow_designer_flow", "scheduled_job", "system_execution"
]);
interface EntityBadgeProps {
type: EntityType;
identitySubtype?: string;
}
export function EntityBadge({ type, identitySubtype }: EntityBadgeProps) {
// If identity type, check if it's automation
const displayType =
type === "identity" && identitySubtype && AUTOMATION_SUBTYPES.has(identitySubtype)
? "automation"
: type;
const colorClass = ENTITY_BADGE_COLORS[displayType] ?? ENTITY_BADGE_COLORS.identity;
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium capitalize ${colorClass}`}>
{displayType}
</span>
);
}
Call sites to update:
ui/src/components/graph/EntityNode.tsx(if exists) — passidentitySubtypepropui/src/pages/EntityDetailPage.tsx— pass subtype fromentity.properties.identitySubtypeui/src/pages/AutomationDetailPage.tsx— sameui/src/components/graph/NodeDetailsDrawer.tsx— same
TypeScript changes:
// ui/src/api/api-types.ts (no change needed — type stays "identity")
// Usage example:
<EntityBadge
type={entity.entity_type}
identitySubtype={entity.properties.identitySubtype as string | undefined}
/>
Why not add "automation" to EntityType enum?
- Breaking change — requires database migration to reclassify entities
- Loses granularity (service_principal, oauth_app would become "automation" too)
- Current model:
entity_type= storage/data model category,identitySubtype= business classification - Recommended: Keep storage model stable, derive display labels in UI
Estimated effort: 2 hours (1 file change + 5-7 call sites)
2. AutomationFlowDiagram Enhancement
Current Flow (5 steps)
TRIGGER (table) → AUTOMATION (SI) → CALLS (REST) → RUNS AS (SP) → CAN ACCESS (resources)
Target Flow (6 steps)
TRIGGER (incident) → ENTRY POINT (BR) → CALLS (SI) → REST (endpoint) → AUTH (SP) → EFFECTS (mutations)
Implementation
File: ui/src/components/AutomationFlowDiagram.tsx
interface FlowStep {
title: string;
primary: string;
secondary?: string;
badge?: React.ReactNode;
dimmed?: boolean;
}
function extractFlowSteps(
automation: EntityDoc,
relatedEntities: Map<string, EntityDoc>,
): FlowStep[] {
const props = automation.properties;
const rels = automation.relationships;
const subtype = (props.identitySubtype as string) ?? "unknown";
// Step 1: TRIGGER — from TRIGGERS_ON relationship
const triggerRel = rels.find((r) => r.type === "TRIGGERS_ON");
const triggerEntity = triggerRel ? relatedEntities.get(triggerRel.target_id) : undefined;
// NEW: If automation is a SI, look for upstream BR CALLS relationship
let entryPointEntity: EntityDoc | undefined;
if (subtype === "system_execution") {
// Find entities that CALL this SI
const callers = [...relatedEntities.values()].filter(e =>
e.relationships.some(r => r.type === "CALLS" && r.target_id === automation._id)
);
entryPointEntity = callers.find(e =>
(e.properties.identitySubtype as string) === "business_rule"
);
}
const triggerStep: FlowStep =
entryPointEntity
? // SI case: show BR's trigger
(() => {
const brTriggerRel = entryPointEntity.relationships.find(r => r.type === "TRIGGERS_ON");
const brTriggerEntity = brTriggerRel ? relatedEntities.get(brTriggerRel.target_id) : undefined;
return brTriggerEntity
? {
title: "TRIGGER",
primary: (brTriggerEntity.properties.display_name as string) ?? brTriggerEntity.source_id,
secondary: brTriggerRel.properties?.triggerType
? `(${brTriggerRel.properties.triggerType})`
: undefined,
}
: { title: "TRIGGER", primary: "Unknown", dimmed: true };
})()
: triggerRel && triggerEntity
? {
title: "TRIGGER",
primary: (triggerEntity.properties.display_name as string) ?? triggerEntity.source_id,
secondary: triggerRel.properties?.triggerType
? `(${triggerRel.properties.triggerType})`
: undefined,
}
: { title: "TRIGGER", primary: "Unknown", dimmed: true };
// Step 2: ENTRY POINT — the BR if viewing SI, otherwise the automation itself
const entryPointStep: FlowStep = entryPointEntity
? {
title: "ENTRY POINT",
primary: (entryPointEntity.properties.display_name as string) ?? entryPointEntity.source_id,
secondary: `BR`,
badge: <AutomationTypeBadge subtype="business_rule" />,
}
: {
title: "AUTOMATION",
primary: (props.display_name as string) ?? automation.source_id,
secondary: (props.execution_count_30d as number | undefined)
? `${props.execution_count_30d} exec/30d`
: undefined,
badge: <AutomationTypeBadge subtype={subtype} />,
};
// Step 3: CALLS — SI if viewing BR, or REST if viewing SI
let callsStep: FlowStep;
if (subtype === "business_rule") {
// BR case: find called SI
const callsRel = rels.find((r) => r.type === "CALLS");
const calledEntity = callsRel ? relatedEntities.get(callsRel.target_id) : undefined;
callsStep = calledEntity
? {
title: "CALLS",
primary: (calledEntity.properties.display_name as string) ?? calledEntity.source_id,
secondary: "Script Include",
badge: <AutomationTypeBadge subtype="system_execution" />,
}
: { title: "CALLS", primary: "None", dimmed: true };
} else {
// SI/other case: show REST endpoint
const executesOnRel = rels.find((r) => r.type === "EXECUTES_ON");
const executesOnEntity = executesOnRel ? relatedEntities.get(executesOnRel.target_id) : undefined;
const egressCategory = props.egress_category as string | undefined;
const egressHost = props.egress_host as string | undefined;
callsStep = executesOnEntity
? {
title: "CALLS",
primary: (executesOnEntity.properties.display_name as string) ?? executesOnEntity.source_id,
secondary: egressHost ?? undefined,
badge: <EgressBadge category={egressCategory} />,
}
: {
title: "CALLS",
primary: egressHost ?? "No outbound call",
secondary: undefined,
badge: <EgressBadge category={egressCategory} />,
dimmed: !egressHost,
};
}
// Step 4: AUTH AS — from RUNS_AS relationship
const runsAsRel = rels.find((r) => r.type === "RUNS_AS");
const runsAsEntity = runsAsRel ? relatedEntities.get(runsAsRel.target_id) : undefined;
const ownershipStatus = props.ownership_status as string | undefined;
const authStep: FlowStep = runsAsEntity
? {
title: "RUNS AS",
primary: (runsAsEntity.properties.display_name as string) ?? runsAsEntity.source_id,
secondary: runsAsEntity.source_system,
badge: <OwnershipBadge status={ownershipStatus} />,
}
: { title: "RUNS AS", primary: "Unknown", dimmed: true, badge: <OwnershipBadge status={ownershipStatus} /> };
// Step 5: EFFECTS — local mutations (new section)
const mutations = props.local_mutations as any[] | undefined;
const mutationCount = mutations?.length ?? 0;
const mutatedTables = mutations
? [...new Set(mutations.map(m => m.table).filter(Boolean))]
: [];
const effectsStep: FlowStep = mutationCount > 0
? {
title: "EFFECTS",
primary: `${mutationCount} mutation${mutationCount !== 1 ? "s" : ""}`,
secondary: mutatedTables.slice(0, 2).join(", ") + (mutatedTables.length > 2 ? "..." : ""),
}
: {
title: "EFFECTS",
primary: "Read-only",
dimmed: true,
};
return [triggerStep, entryPointStep, callsStep, authStep, effectsStep];
}
Key changes:
- Added logic to detect when viewing SI and find upstream BR caller
- Changed "AUTOMATION" to "ENTRY POINT" when showing BR
- Added "CALLS" step that shows SI when viewing BR, REST when viewing SI
- Added "EFFECTS" step showing local_mutations
- Removed "CAN ACCESS" (move to Permissions tab in entity detail)
Data requirements:
local_mutationsproperty on automation nodes (already collected by connector, needs to be emitted)- CALLS edges in graph (see section 4)
Estimated effort: 4 hours (complex conditional logic + testing with different subtypes)
3. Graph Visualization for Full Chains
Current Layer Assignment
Layer 0: triggers, owners
Layer 1: automations (BR, Flow, Job, SI)
Layer 2: credentials, oauth_app
Layer 3: service_principal
Layer 4: roles
Layer 5: permissions
Layer 6: resources (non-trigger)
Problem: BRs and SIs are in the same layer, so CALLS edges don't show left-to-right causality.
Updated Layer Assignment
File: ui/src/components/graph/layout.ts
function executionLayer(entity: EntityDoc, triggerIds: Set<string>): number {
const subtype = entity.properties.identitySubtype as string | undefined;
const type = entity.entity_type;
// Trigger resources (targets of TRIGGERS_ON) — leftmost
if (type === "resource" && triggerIds.has(entity._id)) return 0;
if (type === "owner") return 0;
// NEW: Split automation layer by subtype
if (type === "identity" && subtype === "business_rule") return 1; // Entry points
if (type === "identity" && subtype === "flow_designer_flow") return 1;
if (type === "identity" && subtype === "scheduled_job") return 1;
if (type === "identity" && subtype === "system_execution") return 2; // Called code (SI)
if (type === "credential") return 3;
if (type === "identity" && subtype === "oauth_app") return 3;
if (type === "identity") return 4; // service principals, managed identities
if (type === "role") return 5;
if (type === "permission") return 6;
if (type === "resource") return 7; // non-trigger resources
return 4;
}
Edge styling:
function edgeStyle(relType: string, reversed?: boolean): React.CSSProperties {
const styles: Record<string, React.CSSProperties> = {
// Authority edges (solid)
OWNED_BY: { stroke: "#22c55e", strokeWidth: 2 },
BELONGS_TO: { stroke: "#22c55e", strokeWidth: 1.5 },
HAS_ROLE: { stroke: "#a855f7", strokeWidth: 1.5 },
GRANTS: { stroke: "#f97316", strokeWidth: 1.5 },
APPLIES_TO: { stroke: "#ef4444", strokeWidth: 1.5 },
MEMBER_OF: { stroke: "#a855f7", strokeWidth: 1 },
// Execution edges (dashed)
AUTHENTICATES_TO: { stroke: "#3b82f6", strokeWidth: 2, strokeDasharray: "5 3" },
AUTHENTICATES_VIA: { stroke: "#3b82f6", strokeWidth: 1.5, strokeDasharray: "5 3" },
RUNS_AS: { stroke: "#3b82f6", strokeWidth: 1.5, strokeDasharray: "5 3" },
EXECUTES_ON: { stroke: "#14b8a6", strokeWidth: 1.5, strokeDasharray: "5 3" },
TRIGGERS_ON: { stroke: "#14b8a6", strokeWidth: 1.5, strokeDasharray: "2 3" },
CALLS: { stroke: "#8b5cf6", strokeWidth: 2, strokeDasharray: "4 2" }, // NEW: Purple, prominent
CREATED_BY: { stroke: "#22c55e", strokeWidth: 1, strokeDasharray: "2 3" },
DELEGATES_TO: { stroke: "#3b82f6", strokeWidth: 1, strokeDasharray: "3 3" },
APPROVED_BY: { stroke: "#22c55e", strokeWidth: 1, strokeDasharray: "3 3" },
};
const base = styles[relType] ?? { stroke: "#9ca3af", strokeWidth: 1 };
if (reversed) return { ...base, opacity: 0.45 };
return base;
}
Reverse edge handling:
const EXEC_FLOW_REVERSE_EDGES = new Set([
"OWNED_BY",
"CREATED_BY",
"BELONGS_TO",
"APPROVED_BY",
"AUTHENTICATES_TO",
// NOT "CALLS" — it's a forward execution edge
]);
Visual result:
[Incident Table] --TRIGGERS_ON--> [BR: Auto-route] --CALLS--> [SI: AzureGraphRouter] --EXECUTES_ON--> [REST: graph.microsoft.com] --AUTHENTICATES_VIA--> [OAuth] <--AUTHENTICATES_TO-- [SP: sn-ticket-router]
Layer 0 Layer 1 Layer 2 Layer 7 Layer 3 Layer 4
Dual path handling (2 BRs, 2 SIs, 1 REST, 1 SP):
- Current BFS traversal will include all nodes
- Dagre will handle multiple edges to same target (REST Message)
- Use
nodesep: 80andranksep: 200to prevent overlap
Estimated effort: 3 hours (layer reassignment + edge styling + testing with real data)
4. New Relationship Type: CALLS
Stack-Wide Changes
4.1 Connector: Emit CALLS Edges
File: sv0-connectors/integrations/entra-servicenow/src/entra_servicenow/core/transformer.py
Current state: CALLS edges built in correlator.py but NOT emitted in _process_execution_chain.
Add after line 654 (after EXECUTES_ON edge):
# CALLS edge (BR → SI) if indirect caller exists
# Currently caller_edges have CALLS relationships but they're not emitted
# Need to check if this BR calls the SI in the chain
if hasattr(chain, 'caller_edges'):
for caller_edge in chain.caller_edges:
if (caller_edge.edge_type == "CALLS" and
caller_edge.source_id == br.get("sys_id") and
caller_edge.target_id in [si.get("sys_id") for si in chain.script_includes]):
# Found a BR → SI call in this chain
si_target_node_id = f"sn-si-{caller_edge.target_id}"
self._add_edge(
edge_type="CALLS",
source_node_id=br_node_id,
target_node_id=si_target_node_id,
properties={
"caller_type": "business_rule",
"caller_name": br.get("name", ""),
"invocation_pattern": caller_edge.properties.get("invocation_pattern", ""),
},
)
Alternative (cleaner): Pass caller_edges to _process_execution_chain and emit all CALLS edges.
def transform(
self,
integrations: list[Integration],
execution_chains: list[ExecutionChain],
flows: list[dict] | None = None,
execution_data: dict[str, dict] | None = None,
filter_internal_inventory: bool = False,
) -> dict:
# ... existing code ...
# Process execution chains (may reference already-created nodes)
for chain in execution_chains:
self._process_execution_chain(chain)
# NEW: Emit CALLS edges from caller_edges (if using DiscoveredEntities path)
if hasattr(self, '_caller_edges'):
for edge in self._caller_edges:
if edge.edge_type == "CALLS":
self._add_edge(
edge_type="CALLS",
source_node_id=f"sn-{edge.properties.get('caller_type', 'auto')}-{edge.source_id}",
target_node_id=self._resolve_target_node_id(edge),
properties={
"caller_type": edge.properties.get("caller_type", ""),
"caller_name": edge.properties.get("caller_name", ""),
"invocation_pattern": edge.properties.get("invocation_pattern", ""),
},
)
Better approach: Modify _build_legacy_objects to store caller_edges in ExecutionChain, then emit in _process_execution_chain.
Estimated effort: 2 hours (connector changes + unit tests)
4.2 Platform Ingestion Types
File: sv0-platform/src/ingestion/types.ts
export type NormalizedEdgeType =
| "OWNED_BY"
| "BELONGS_TO"
| "HAS_ROLE"
| "GRANTS"
| "APPLIES_TO"
| "AUTHENTICATES_TO"
| "AUTHENTICATES_VIA"
| "EXECUTES_ON"
| "RUNS_AS"
| "TRIGGERS_ON"
| "CALLS" // NEW
| "CREATED_BY"
| "DELEGATES_TO"
| "APPROVED_BY"
| "MEMBER_OF";
Estimated effort: 5 minutes
4.3 Platform Domain Types
File: sv0-platform/src/domain/graph/relationship-types.ts
export const RELATIONSHIP_TYPES = [
"OWNED_BY",
"BELONGS_TO",
"HAS_ROLE",
"GRANTS",
"APPLIES_TO",
"AUTHENTICATES_TO",
"AUTHENTICATES_VIA",
"EXECUTES_ON",
"RUNS_AS",
"TRIGGERS_ON",
"CALLS", // NEW
"CREATED_BY",
"DELEGATES_TO",
"APPROVED_BY",
"MEMBER_OF"
] as const;
Estimated effort: 5 minutes
4.4 Platform Storage: Subgraph Traversal
File: sv0-platform/src/storage/mongo/adapter.ts
No changes needed — executionFlowTraversal already includes all forward edges:
// Line 607-624: Forward edges from frontier entities
for (const id of frontier) {
const entity = visited.get(id)!;
for (const rel of entity.relationships) {
if (query.relationshipTypes && !query.relationshipTypes.includes(rel.type)) continue;
// ... adds edge to result
}
}
CALLS edges will be automatically included in forward traversal.
Estimated effort: 0 hours (no change needed)
4.5 Platform UI Types
File: sv0-platform/ui/src/api/api-types.ts
export type RelationshipType =
| "OWNED_BY"
| "BELONGS_TO"
| "HAS_ROLE"
| "GRANTS"
| "APPLIES_TO"
| "AUTHENTICATES_TO"
| "AUTHENTICATES_VIA"
| "EXECUTES_ON"
| "RUNS_AS"
| "TRIGGERS_ON"
| "CALLS" // NEW
| "CREATED_BY"
| "DELEGATES_TO"
| "APPROVED_BY"
| "MEMBER_OF";
Estimated effort: 5 minutes
Total for CALLS relationship type: 3 hours (mostly connector + tests)
5. Trigger Record Display
Current State
Connector collects trigger_examples (recent incident records that fired the BR) but doesn't emit them.
Proposed Design: Properties on Automation Node
Option C (Recommended): Store as automation node property — No new entity type, simpler model.
# In transformer.py, when creating BR node:
br_node_id = self._add_node(
node_id=f"sn-br-{br_sys_id}",
node_type="autonomous_identity",
source_system="servicenow",
source_id=br_sys_id,
display_name=br.get("name", "Unknown Business Rule"),
status="active" if br.get("active", True) else "disabled",
created_at=br.get("sys_created_on"),
properties={
"identitySubtype": "business_rule",
"automation_type": "business_rule",
"table": br.get("table", ""),
"when": br.get("when", ""),
"sys_created_by": br.get("sys_created_by", ""),
"sys_updated_by": br.get("sys_updated_by", ""),
# NEW: Trigger context
"trigger_examples": chain.trigger_examples[:5], # Limit to 5 recent records
"trigger_count_30d": len(chain.trigger_examples) if chain.trigger_examples else 0,
},
)
UI Display: New "Triggers" section in AutomationFlowDiagram
// After the main flow diagram, add:
function TriggerExamplesSection({ automation }: { automation: EntityDoc }) {
const examples = (automation.properties.trigger_examples as any[]) ?? [];
if (examples.length === 0) return null;
return (
<div className="mt-4 rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-400">
Recent Trigger Events
</h3>
<div className="space-y-2">
{examples.slice(0, 3).map((ex, i) => (
<div key={i} className="rounded border border-gray-100 bg-gray-50 p-2 text-xs">
<div className="flex items-baseline justify-between">
<span className="font-medium text-gray-900">{ex.number || ex.sys_id}</span>
<span className="text-gray-500">{timeAgo(new Date(ex.sys_created_on))}</span>
</div>
{ex.short_description && (
<div className="mt-1 text-gray-600">{ex.short_description}</div>
)}
</div>
))}
</div>
</div>
);
}
Why not nodes?
- Trigger records are ephemeral evidence, not persistent entities
- Adding 100s of incident nodes would clutter the graph
- Better UX: show as contextual data in the automation detail view
API changes needed:
- None (properties are already part of EntityDoc)
Estimated effort: 3 hours (connector property emit + UI section + styling)
6. Record Changes / Mutations Display
Current State
Connector collects local_mutations via analyze_script_mutations():
{
"table": "incident",
"operation": "setValue",
"field": "assignment_group",
"value": "...",
"line_number": 42,
}
Proposed Design: New "Effects" Tab in Entity Detail Page
Option B (Recommended): New tab in AutomationDetailPage — Dedicated space for mutation analysis.
File: ui/src/pages/AutomationDetailPage.tsx
type Tab = "overview" | "graph" | "effects" | "findings" | "timeline";
const TABS: { key: Tab; label: string }[] = [
{ key: "overview", label: "Overview" },
{ key: "graph", label: "Graph" },
{ key: "effects", label: "Effects" }, // NEW
{ key: "findings", label: "Findings" },
{ key: "timeline", label: "Timeline" },
];
// In render logic:
{activeTab === "effects" && <EffectsTab automation={entity} />}
New component: ui/src/components/EffectsTab.tsx
import type { EntityDoc } from "../api/api-types.ts";
interface Mutation {
table: string;
operation: string;
field: string;
value?: string;
line_number?: number;
}
export function EffectsTab({ automation }: { automation: EntityDoc }) {
const mutations = (automation.properties.local_mutations as Mutation[]) ?? [];
if (mutations.length === 0) {
return (
<EmptyState
title="No mutations detected"
description="This automation does not modify ServiceNow records."
/>
);
}
// Group by table
const byTable = mutations.reduce((acc, m) => {
const table = m.table || "unknown";
if (!acc[table]) acc[table] = [];
acc[table].push(m);
return acc;
}, {} as Record<string, Mutation[]>);
return (
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-2 text-sm font-semibold text-gray-900">
Data Mutations ({mutations.length})
</h3>
<p className="mb-4 text-xs text-gray-600">
Fields modified by this automation during execution.
</p>
{Object.entries(byTable).map(([table, muts]) => (
<div key={table} className="mb-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
{table}
</h4>
<div className="space-y-1">
{muts.map((m, i) => (
<div key={i} className="flex items-baseline justify-between rounded border border-gray-100 bg-gray-50 px-3 py-1.5">
<div className="flex items-baseline gap-2">
<span className="font-mono text-xs font-medium text-blue-700">{m.operation}</span>
<span className="font-mono text-xs text-gray-700">{m.field}</span>
{m.value && <span className="text-xs text-gray-500">= {m.value.slice(0, 40)}</span>}
</div>
{m.line_number && (
<span className="text-xs text-gray-400">line {m.line_number}</span>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}
Connector changes:
# In transformer.py _process_execution_chain, when creating BR/SI nodes:
properties={
# ... existing properties ...
"local_mutations": chain.local_mutations, # Already collected, just need to emit
}
Why a separate tab?
- Keeps Overview clean and focused
- Allows detailed view with grouping, filtering, code line numbers
- Future: could add diff view, impact analysis, compliance tagging
Estimated effort: 4 hours (connector property emit + UI tab + component + styling)
7. Implementation Effort Estimate
Breakdown by Component
| Component | Task | Hours | Notes |
|---|---|---|---|
| UI: Entity Badge | Derive "automation" label from identitySubtype | 2 | 1 file + 5-7 call sites |
| UI: Flow Diagram | 6-step flow with BR→SI logic | 4 | Complex conditionals |
| UI: Graph Layout | Split automation layers (BR=1, SI=2) | 3 | Layer reassignment + testing |
| UI: Graph Styling | CALLS edge styling (purple, dashed) | 0.5 | Simple CSS addition |
| UI: Trigger Section | Display trigger_examples in flow diagram | 3 | New component + styling |
| UI: Effects Tab | New tab showing local_mutations | 4 | New component + grouping logic |
| Connector: CALLS Emit | Emit CALLS edges in transformer | 2 | Edge emission + unit tests |
| Connector: Properties | Emit trigger_examples, local_mutations | 1 | Property additions |
| Platform API: Types | Add CALLS to NormalizedEdgeType | 0.5 | Type definitions only |
| Platform API: Storage | (No changes needed) | 0 | BFS already handles all edges |
| Testing: Integration | E2E test with BR→SI→REST chain | 4 | New test data + assertions |
| Testing: UI | Component tests for new UI elements | 3 | Flow diagram + Effects tab |
| Documentation | Update architecture docs with CALLS | 1 | Add to relationship-types.md |
Total estimated effort: 28 hours (3.5 developer days)
Phasing Recommendation
Phase 1: Core Execution Flow (12 hours)
- Add CALLS relationship type (all stack layers)
- Emit CALLS edges in connector
- Update graph layout layers
- Basic flow diagram enhancement (show BR when viewing SI)
Phase 2: Trigger Context (6 hours)
- Emit trigger_examples in connector
- Add trigger section to AutomationFlowDiagram
- Test with real incident data
Phase 3: Mutation Effects (10 hours)
- Emit local_mutations in connector
- Build Effects tab component
- Add grouping and formatting logic
- Integration testing
8. Risk Assessment
Low Risk
- Type additions (CALLS relationship) — additive, no breaking changes
- UI label derivation — display-only, no data changes
- Property additions (trigger_examples, local_mutations) — optional fields
Medium Risk
- Graph layout layer changes — could affect existing visualizations
- Mitigation: Add layout mode toggle (compact vs expanded)
- Flow diagram logic complexity — nested conditionals for BR/SI detection
- Mitigation: Extract helper functions, add unit tests
High Risk
- None identified
9. Testing Strategy
Unit Tests
Connector:
# test_transformer.py
def test_calls_edge_emission():
"""Verify BR→SI CALLS edges are emitted in NormalizedGraph."""
# Given: BR that calls SI
br = {"sys_id": "br1", "name": "AutoRoute", "script": "new AzureGraphRouter().route()"}
si = {"sys_id": "si1", "name": "AzureGraphRouter"}
caller_edge = ResolvedEdge(source_id="br1", target_id="si1", edge_type="CALLS")
# When: transform
graph = transformer.transform_entities(entities)
# Then: CALLS edge exists
calls_edges = [e for e in graph["edges"] if e["edgeType"] == "CALLS"]
assert len(calls_edges) == 1
assert calls_edges[0]["sourceNodeId"] == "sn-br-br1"
assert calls_edges[0]["targetNodeId"] == "sn-si-si1"
Platform UI:
// AutomationFlowDiagram.test.tsx
describe("AutomationFlowDiagram with BR→SI chain", () => {
it("shows BR as entry point when viewing SI", () => {
const si = mockEntity({
properties: { identitySubtype: "system_execution", display_name: "AzureGraphRouter" },
relationships: [],
});
const br = mockEntity({
properties: { identitySubtype: "business_rule", display_name: "Auto-route tickets" },
relationships: [
{ type: "CALLS", target_id: si._id, properties: {} },
{ type: "TRIGGERS_ON", target_id: "tbl-incident", properties: {} },
],
});
const relatedMap = new Map([[br._id, br], ["tbl-incident", mockTable()]]);
const { getByText } = render(<AutomationFlowDiagram automation={si} relatedEntities={relatedMap} />);
expect(getByText("ENTRY POINT")).toBeInTheDocument();
expect(getByText("Auto-route tickets")).toBeInTheDocument();
});
});
Integration Tests
E2E test: Create BR→SI→REST→SP chain, verify:
- CALLS edge persisted in database
- Graph API returns all 4 nodes in execution_flow mode
- UI renders 6-step flow diagram
- Graph shows correct layer ordering
10. Rollout Plan
Step 1: Backend Changes (Week 1)
- Add CALLS to platform types
- Update connector to emit CALLS edges
- Deploy platform API changes
- Verify CALLS edges appear in database
Step 2: UI Core (Week 1)
- Entity badge label derivation
- Graph layout layer split
- CALLS edge styling
- Deploy UI changes
Step 3: Flow Enhancements (Week 2)
- Update AutomationFlowDiagram with BR→SI logic
- Add trigger_examples display
- Integration testing
Step 4: Effects Tab (Week 2)
- Build Effects tab component
- Emit local_mutations from connector
- User acceptance testing
11. Open Questions
-
Filtering: Should CALLS edges be visible in
neighborhoodmode, or onlyexecution_flow?- Recommendation: Show in both modes — it's structural information, not just execution.
-
Circular CALLS: Can SIs call each other recursively?
- Recommendation: BFS will handle cycles naturally (visited set prevents infinite loops).
-
Multiple BRs calling same SI: How to display in flow diagram?
- Recommendation: Show first BR found, add count badge ("2 entry points"), link to graph tab for full view.
-
Performance: Will 200+ automation nodes slow down graph rendering?
- Recommendation: Use virtualization in node list, limit subgraph depth to 3 hops.
12. Success Criteria
-
When viewing
AzureGraphRouter(SI), the flow diagram shows:- TRIGGER: "incident"
- ENTRY POINT: "Auto-route identity tickets via Entra" (BR)
- CALLS: "AzureGraphRouter" (SI)
- REST: "graph.microsoft.com/v1.0/users"
- AUTH: "sn-ticket-router" (SP)
- EFFECTS: "2 mutations (incident, sys_user)"
-
Graph view shows:
- 2 BRs (layer 1)
- 2 SIs (layer 2)
- 1 REST Message (layer 7)
- 1 SP (layer 4)
- Purple dashed CALLS edges connecting BR→SI
-
Effects tab displays:
- Grouped mutations by table
- Field names and operations
- Line numbers from script
-
Entity badge shows "automation" for all BR/Flow/Job/SI nodes.
-
All 247 existing tests pass + 8 new tests added.
Appendix A: Example Data Structures
Trigger Example (from ServiceNow incident table)
{
"sys_id": "abc123",
"number": "INC0010023",
"short_description": "User cannot access shared mailbox after org change",
"sys_created_on": "2026-02-10T14:32:00Z",
"caller_id": "user456"
}
Local Mutation (from script analysis)
{
"table": "incident",
"operation": "setValue",
"field": "assignment_group",
"value": "Identity Management",
"line_number": 42
}
CALLS Edge Properties
{
"caller_type": "business_rule",
"caller_name": "Auto-route identity tickets via Entra",
"invocation_pattern": "new AzureGraphRouter().routeIncident(current)",
"target_type": "script_include",
"target_name": "AzureGraphRouter"
}
End of Developer Analysis