Skip to main content

Execution Flow Provenance — Architect Technical Analysis

Role: Architect Date: 2026-02-13 Scope: Data model, API, and graph visualization redesign for full execution provenance chains


Executive Summary

The platform today models execution chains as disconnected subgraphs. A Business Rule and the Script Include it calls are separate identity entities with parallel edges to shared targets (REST Message, Service Principal) but no edge connecting them to each other. The connector builds CALLS edges internally in EdgeResolver.resolve_indirect_caller_edges() but the transformer collapses them: both the BR and SI independently get EXECUTES_ON -> REST Message and RUNS_AS -> SP edges, losing the causal invocation chain.

This analysis proposes six specific changes — none of which require a new entity_type, and all of which are backward-compatible — to restore full execution provenance visibility.


1. Data Model Changes

1a. Entity Type: Keep identity, Do Not Add automation

Recommendation: Keep autonomous_identity as the NormalizedNodeType. Do NOT add a new entity type.

The rationale is structural, not cosmetic. The platform's path materializer, evaluator, and storage adapter all branch on entity_type: "identity". An automation Business Rule participates in the same graph as a Service Principal:

BR -[RUNS_AS]-> SP -[HAS_ROLE]-> Role -[GRANTS]-> Permission -[APPLIES_TO]-> Resource

The path materializer at /Users/lucky/dev/securityv0/sv0-platform/src/ingestion/path-materializer.ts lines 27-28 filters on entity_type !== "identity" to skip non-identity entities. If automations were a separate type, every traversal that starts from an automation would require special-case handling to "jump across" entity type boundaries.

The identitySubtype property (business_rule, system_execution, scheduled_job, flow_designer_flow, service_principal, oauth_app) already provides the discrimination needed for display labels, filtering, and layer assignment. The UI should use identitySubtype for rendering — showing "Business Rule" not "identity" — but the storage type remains identity.

What to fix instead: The UI's EntityNode.tsx component (line 31) renders nodeData.entityType as the label. It should render identitySubtype when available:

// Current: shows "identity" for everything
<span className={`text-[10px] ${colors.text}`}>{nodeData.entityType}</span>

// Proposed: show identitySubtype when available
<span className={`text-[10px] ${colors.text}`}>
{nodeData.identitySubtype ?? nodeData.entityType}
</span>

Similarly, the ENTITY_COLORS map should have subtype-specific colors for automation entities (business_rule, system_execution, etc.) so they are visually distinguishable from service principals and OAuth apps.

1b. Add CALLS Relationship Type

Recommendation: Add CALLS to the platform's relationship types. The connector should emit it.

The CALLS edge represents an explicit invocation: one code artifact invokes another. It is fundamentally different from EXECUTES_ON (which means "makes API calls to this external resource") and from RUNS_AS (which means "uses this identity's authority").

The chain we need to represent:

BR: "Auto-route identity tickets via Entra"
-[CALLS]-> SI: AzureGraphRouter
-[EXECUTES_ON]-> REST Message: "Graph - sn-ticket-router"
-[AUTHENTICATES_VIA]-> OAuth: Azure Graph OAuth Client
-[AUTHENTICATES_TO]-> SP: sn-ticket-router

Without CALLS, the graph shows two parallel chains that happen to share a REST Message target:

  • BR -> EXECUTES_ON -> REST Message -> ...
  • SI -> EXECUTES_ON -> REST Message -> ...

This is semantically wrong. The BR does not directly execute on the REST Message. The BR invokes the SI (by calling new AzureGraphRouter().execute()), and the SI calls the REST Message. The current model loses the causal ordering.

Where the data exists today:

In /Users/lucky/dev/securityv0/sv0-connectors/integrations/entra-servicenow/src/entra_servicenow/core/correlator.py, EdgeResolver.resolve_indirect_caller_edges() (lines 501-558) already resolves CALLS edges from BR/Job -> Script Include by scanning script content for SI name references. These ResolvedEdge objects with edge_type="CALLS" are stored in DiscoveredEntities.caller_edges.

In _build_legacy_objects() of transformer.py (lines 211-234), the caller_edges with target_type == "script_include" are used to populate indirect_callers_by_si, but then the transformer emits both the BR and SI as flat nodes with independent EXECUTES_ON and RUNS_AS edges, dropping the CALLS relationship.

Changes required:

  1. Add "CALLS" to RELATIONSHIP_TYPES in /Users/lucky/dev/securityv0/sv0-platform/src/domain/graph/relationship-types.ts
  2. Add "CALLS" to NormalizedEdgeType in /Users/lucky/dev/securityv0/sv0-platform/src/ingestion/types.ts
  3. Modify _process_execution_chain() in transformer.py to emit CALLS edges from BR -> SI when the BR is an indirect caller
  4. When a BR calls an SI that calls a REST Message, only the SI should get EXECUTES_ON -> REST Message. The BR should get CALLS -> SI (and still keeps TRIGGERS_ON -> table). The BR should NOT get EXECUTES_ON -> REST Message.
  5. The BR's RUNS_AS -> SP edge should also be removed if the BR does not directly call the REST Message. Only the SI that actually makes the HTTP call should have RUNS_AS -> SP.

Edge semantics:

EdgeMeaningFrom -> To
CALLS"This code artifact invokes this other code artifact"BR -> SI, Job -> SI
EXECUTES_ON"This code artifact makes HTTP/API calls to this external resource"SI -> REST Message, Flow -> HTTP Endpoint
RUNS_AS"This automation uses this identity's authority"SI -> SP (only on the artifact that actually authenticates)
TRIGGERS_ON"This automation fires when this event occurs on this resource"BR -> table, Flow -> table

1c. Do Not Model Trigger Records as Nodes

Recommendation: Do NOT create nodes for individual trigger instances (e.g., INC0010023).

Trigger records are execution evidence, not graph topology. Creating a node for every incident that fired a Business Rule would be:

  • Scale problem: A BR on the incident table with on_after_insert fires potentially thousands of times per day.
  • Temporal mismatch: The graph represents authority structure (what can happen). Individual trigger instances represent what did happen — that is the domain of ExecutionEvidence documents.
  • No provenance value: Knowing that INC0010023 fired the BR tells you nothing about authority or blast radius that the BR's TRIGGERS_ON -> incident edge does not already express.

What to do instead: Trigger examples should be stored as ExecutionEvidence records attached to the BR entity. The connector's get_recent_trigger_examples() already fetches this data. It should be emitted as execution_evidence nodes linked to the BR, not as resource nodes in the topology graph.

The trigger_info property on BR/Flow nodes already carries the table name and event types. This is sufficient for the graph. The actual trigger instances (INC0010023, etc.) belong in the temporal evidence layer.

1d. Do Not Model Record Changes as Topology Nodes (Yet)

Recommendation: Do NOT create nodes for script mutations (setValue, setWorkflow, GlideRecord ops) in this iteration.

The connector's analyze_script_mutations() detects that a script calls current.assignment_group.setValue(...) and current.setWorkflow(false). These are valuable for blast radius analysis — they tell you what the automation writes to in addition to what it reads from. However, modeling them as graph nodes creates three problems:

  1. Static analysis vs. runtime: Script analysis produces potential mutations. Without runtime evidence, we cannot confirm they actually execute in the control flow path that is taken.
  2. Redundancy: The mutations target tables that may already have nodes (e.g., incident table is already in the graph as a TRIGGERS_ON target).
  3. Directionality confusion: A mutation is an effect of execution, not a structural relationship. The graph models authority chains (what can happen), not effect chains (what does happen to data).

Future path: When the platform adds an "effects" layer (Phase 10+), mutations should be modeled as a separate relationship type (MUTATES or WRITES_TO) from the automation to the table resource, with properties indicating the operation type (insert, update, delete) and fields affected. This is an additive change that does not affect the current topology.

For now: Store mutations as structured properties on the automation node:

{
"local_mutations": [
{"table": "incident", "operation": "update", "fields": ["assignment_group", "work_notes"]},
{"table": "incident", "operation": "workflow_control", "fields": ["setWorkflow(false)"]}
]
}

This data is already collected. It just needs to be propagated through the transformer into the node's properties, where the UI can render it in the entity detail view.


2. CALLS Edge: Emit, Do Not Infer

Recommendation: The connector must emit CALLS edges explicitly in the NormalizedGraph.

Against inference: The platform could theoretically infer CALLS edges by detecting that two identity nodes share a REST Message target (both have EXECUTES_ON -> same REST Message). This is fragile for three reasons:

  1. Ambiguity: Two automations that independently call the same REST Message are not in a CALLS relationship. A Business Rule might call the Azure Graph API directly (via RESTMessageV2) and a separate Script Include might also call it. No invocation chain exists between them.

  2. Direction: Inference cannot determine which entity calls which. The BR calls the SI, not vice versa. Only the connector has the script content needed to determine invocation direction.

  3. Determinism constraint: SecurityV0's core design principle is "no heuristics, no inference." If the connector can determine the CALLS relationship by scanning script content (which it already does), the platform should not re-derive it via a weaker heuristic.

Against a new relationship name: INVOKES was considered as an alternative to CALLS. Both mean the same thing. CALLS is already used in EdgeResolver and matches ServiceNow's Script Include invocation semantics (new AzureGraphRouter()). Introducing INVOKES would create two names for the same concept. Stick with CALLS.

What changes in the transformer

Currently in _process_execution_chain() (transformer.py lines 611-655), for indirect Business Rules (those that call a Script Include):

# Current: BR gets EXECUTES_ON -> REST Message (WRONG for indirect callers)
if rest_msg_node_id:
self._add_edge(
edge_type="EXECUTES_ON",
source_node_id=br_node_id,
target_node_id=rest_msg_node_id,
)

This should change to:

# Proposed: Indirect BR gets CALLS -> SI, not EXECUTES_ON -> REST Message
is_indirect = br in chain.indirect_business_rules
if is_indirect and chain.script_includes:
# BR calls the Script Include, not the REST Message directly
for si in chain.script_includes:
si_sys_id = si.get("sys_id", "unknown")
si_node_id = f"sn-si-{si_sys_id}"
self._add_edge(
edge_type="CALLS",
source_node_id=br_node_id,
target_node_id=si_node_id,
properties={
"invocationType": "script_reference",
"callerType": "business_rule",
},
)
elif rest_msg_node_id:
# Direct BR calls REST Message directly (no SI intermediary)
self._add_edge(
edge_type="EXECUTES_ON",
source_node_id=br_node_id,
target_node_id=rest_msg_node_id,
)

Similarly, RUNS_AS should only be emitted for the entity that actually authenticates:

  • Indirect BR: Gets CALLS -> SI, TRIGGERS_ON -> table, OWNED_BY -> creator. Does NOT get RUNS_AS -> SP or EXECUTES_ON -> REST Message.
  • Script Include: Gets EXECUTES_ON -> REST Message, RUNS_AS -> SP, OWNED_BY -> creator. This is the artifact that actually makes the HTTP call.
  • Direct BR (no SI intermediary): Keeps current behavior — EXECUTES_ON -> REST Message, RUNS_AS -> SP, TRIGGERS_ON -> table.

3. Execution Flow API Improvements

Current Behavior

The subgraph API's execution_flow mode (in /Users/lucky/dev/securityv0/sv0-platform/src/storage/mongo/adapter.ts, lines 586-681) performs a BFS with selective reverse lookups:

  • For automation seeds: Forward edges from the seed + reverse AUTHENTICATES_TO edges pointing at frontier nodes.
  • For identity seeds (e.g., Service Principal): Forward edges + reverse RUNS_AS and AUTHENTICATES_TO.

This means: starting from a Business Rule, the traversal follows:

  1. BR -> EXECUTES_ON -> REST Message (forward)
  2. BR -> RUNS_AS -> SP (forward)
  3. BR -> TRIGGERS_ON -> table (forward)
  4. OAuth -> AUTHENTICATES_TO -> SP (reverse, pulling in the OAuth node)
  5. REST Message -> AUTHENTICATES_VIA -> OAuth (forward from REST Message)

Problems with Current Traversal

  1. No CALLS support: When CALLS edges are added, the traversal needs to follow CALLS as a forward edge from the seed BR, reaching the SI, then continuing forward from the SI.

  2. Incomplete chain from identity seeds: Starting from an SP, the reverse RUNS_AS lookup finds automations that use this SP. But it does not then follow CALLS edges backward to find the BR that triggers the SI. The traversal stops at the SI without showing what triggers it.

  3. Depth limitation: MAX_DEPTH is 3, but a full provenance chain now has 6-8 hops:

    table (trigger) <- BR -CALLS-> SI -EXECUTES_ON-> REST Msg -AUTHENTICATES_VIA-> OAuth <-AUTHENTICATES_TO- SP

    This requires depth=4 minimum for forward traversal from a trigger resource, and depth=5 to include roles and permissions.

Proposed Changes

3a. Add CALLS to forward traversal

No code change needed in the BFS itself — CALLS is just another forward edge that the existing traversal will follow. The only change is adding "CALLS" to the AUTOMATION_SUBTYPES check that determines which traversal pattern to use. Since CALLS edges flow from automations to other automations, the existing forward-edge traversal handles them automatically.

3b. Add CALLS to reverse lookup for identity seeds

When the seed is an identity (SP), the current code does reverse lookups for RUNS_AS and AUTHENTICATES_TO. After finding an SI via reverse RUNS_AS, it should also do a reverse CALLS lookup to find BRs/Jobs that invoke that SI.

// Current reverse types for identity seeds
const reverseTypes = isAutomationSeed
? new Set(["AUTHENTICATES_TO"])
: new Set(["RUNS_AS", "AUTHENTICATES_TO"]);

// Proposed: add CALLS to both patterns
const reverseTypes = isAutomationSeed
? new Set(["AUTHENTICATES_TO", "CALLS"])
: new Set(["RUNS_AS", "AUTHENTICATES_TO", "CALLS"]);

Wait — this needs more thought. For an automation seed (BR), we do NOT want reverse CALLS (that would find the BR's callers, going upstream). We want forward CALLS (BR -> SI), which the BFS already handles. The reverse CALLS is needed for identity seeds and for SIs (to find the BR that calls them).

Better:

const reverseTypes = isAutomationSeed
? new Set(["AUTHENTICATES_TO"])
: new Set(["RUNS_AS", "AUTHENTICATES_TO", "CALLS"]);

This way:

  • From a BR seed: forward BFS finds SI via CALLS, REST Message via EXECUTES_ON (from SI), SP via RUNS_AS (from SI), OAuth via reverse AUTHENTICATES_TO, table via TRIGGERS_ON.
  • From an SP seed: reverse RUNS_AS finds SI, reverse CALLS finds BR that calls SI, forward TRIGGERS_ON from BR finds table.

3c. Increase MAX_DEPTH for execution_flow

The current MAX_DEPTH = 3 in the graph routes (/Users/lucky/dev/securityv0/sv0-platform/src/api/routes/graph.ts, line 7) is insufficient. A full chain from trigger to resource is:

table -[TRIGGERS_ON]<- BR -[CALLS]-> SI -[EXECUTES_ON]-> REST -[AUTHENTICATES_VIA]-> OAuth
<-[AUTHENTICATES_TO]- SP
SP -[HAS_ROLE]-> Role -[GRANTS]-> Perm -[APPLIES_TO]-> Resource

That is 7 hops from table to target resource. But BFS is bidirectional in neighborhood mode and uses reverse lookups in execution_flow mode, so depth=4 typically suffices for practical chains. The BFS from a BR seed at depth=3 reaches: SI (1), REST Message (2), OAuth (2, via reverse from SP side), SP (3). This is acceptable.

Recommendation: Increase MAX_DEPTH from 3 to 4. The API already validates depth against MAX_DEPTH. This is a single constant change:

const MAX_DEPTH = 4;  // was 3

3d. Add a new provenance_chain mode (future)

For the specific use case of "show me the complete execution chain for this automation," a dedicated mode could be more efficient than general BFS. This mode would:

  1. Start from a seed automation entity.
  2. Follow CALLS forward (find all code artifacts in the chain).
  3. Follow TRIGGERS_ON backward from the seed (find trigger resources).
  4. From the terminal automation (the one with EXECUTES_ON), follow the auth chain: EXECUTES_ON -> REST -> AUTHENTICATES_VIA -> OAuth -> AUTHENTICATES_TO -> SP.
  5. From the SP, optionally follow HAS_ROLE -> GRANTS -> APPLIES_TO to show authority.

This is an optimization for a common access pattern. The existing execution_flow mode with CALLS support covers the same ground via BFS, just less efficiently. I recommend deferring this to a future phase and relying on execution_flow + increased depth for now.


4. Graph Layout for Full Chains

Current Layer Assignment

From /Users/lucky/dev/securityv0/sv0-platform/ui/src/components/graph/layout.ts, executionLayer() (lines 38-53):

LayerEntity Types
0Trigger resources (targets of TRIGGERS_ON), owners
1Automations (business_rule, flow_designer_flow, scheduled_job, system_execution)
2Credentials, oauth_app
3Service principals, managed identities (generic identity)
4Roles
5Permissions
6Non-trigger resources

Problem: All Automations Are Layer 1

With CALLS edges, the graph now has a causal chain within the automation layer:

BR (fires on event) -> SI (code library) -> REST Message (HTTP target)

The BR should precede the SI in left-to-right flow. But both are identitySubtype in AUTOMATION_SUBTYPES, so both get layer 1. Dagre resolves this via the CALLS edge between them, but only if the edge direction aligns with the rank direction. Since the layout uses rankdir: "LR" and CALLS flows left-to-right (BR -> SI), dagre should handle this correctly.

However, there is a subtlety: the invisible constraint edges (lines 99-113) enforce layer ordering by picking one representative node per layer. If the BR and SI are both in layer 1, the constraint edge skips them. The CALLS edge between them provides the ordering, but it competes with other edges.

Proposed Layer Assignment

Split the automation layer into two sub-layers — "trigger automations" (things that fire on events) and "code artifacts" (things that are invoked by other automations):

LayerEntity TypesDescription
0Trigger resources (TRIGGERS_ON targets)Tables/events that start the chain
0Owners, creatorsContext entities
1Trigger automations (business_rule, scheduled_job, flow_designer_flow)Things that fire on events
2Code artifacts (system_execution / script_include)Things invoked by trigger automations
3REST Messages, HTTP EndpointsOutbound call targets
4OAuth apps, credentialsAuthentication infrastructure
5Service principals, managed identitiesCloud identities
6RolesAuthority groupings
7PermissionsIndividual capabilities
8Target resources (non-trigger)API endpoints, cloud resources

This gives the full AzureGraphRouter chain a clean left-to-right flow:

Layer 0        Layer 1      Layer 2         Layer 3            Layer 4              Layer 5
incident -> BR: Auto- SI: Azure -> REST: Graph - -> OAuth: Azure <- SP: sn-ticket-
(table) route... GraphRouter sn-ticket-router Graph OAuth router
[TRIGGERS_ON] [CALLS] [EXECUTES_ON] [AUTHENTICATES_VIA] [AUTHENTICATES_TO]

Implementation

Update executionLayer() in layout.ts:

function executionLayer(entity: EntityDoc, triggerIds: Set<string>): number {
const subtype = entity.properties.identitySubtype as string | undefined;
const type = entity.entity_type;

// Layer 0: Triggers and context
if (type === "resource" && triggerIds.has(entity._id)) return 0;
if (type === "owner") return 0;

// Layer 1: Trigger automations (fire on events)
if (type === "identity" && subtype && TRIGGER_AUTOMATION_SUBTYPES.has(subtype)) return 1;

// Layer 2: Code artifacts (invoked by trigger automations)
if (type === "identity" && subtype && CODE_ARTIFACT_SUBTYPES.has(subtype)) return 2;

// Layer 3: Outbound resources (REST Messages, HTTP endpoints)
if (type === "resource" && isOutboundResource(entity)) return 3;

// Layer 4: Auth infrastructure
if (type === "credential") return 4;
if (type === "identity" && subtype && OAUTH_SUBTYPES.has(subtype)) return 4;

// Layer 5: Cloud identities
if (type === "identity") return 5;

// Layer 6-8: Authority chain
if (type === "role") return 6;
if (type === "permission") return 7;
if (type === "resource") return 8;

return 5;
}

const TRIGGER_AUTOMATION_SUBTYPES = new Set([
"business_rule", "scheduled_job", "flow_designer_flow"
]);
const CODE_ARTIFACT_SUBTYPES = new Set(["system_execution"]);
const OAUTH_SUBTYPES = new Set(["oauth_app"]);

function isOutboundResource(entity: EntityDoc): boolean {
const resourceType = entity.properties.resourceType as string | undefined;
return resourceType === "rest_message" || resourceType === "http_endpoint";
}

EXEC_FLOW_REVERSE_EDGES Update

With CALLS edges added, the reverse-edge set for dagre layout needs updating. CALLS flows forward (BR -> SI), so it should NOT be reversed:

const EXEC_FLOW_REVERSE_EDGES = new Set([
"OWNED_BY",
"CREATED_BY",
"BELONGS_TO",
"APPROVED_BY",
"AUTHENTICATES_TO", // Keep reversed: SP -> OAuth, but SP should be right of OAuth
]);
// CALLS is NOT in this set — it flows left-to-right (BR -> SI)

5. Schema Migration Path

Phase A: Additive (Backward-Compatible)

These changes add new capabilities without breaking existing data or API contracts.

A1. Add CALLS to platform relationship types

File: /Users/lucky/dev/securityv0/sv0-platform/src/domain/graph/relationship-types.ts

export const RELATIONSHIP_TYPES = [
// ... existing 14 types ...
"CALLS"
] as const;

File: /Users/lucky/dev/securityv0/sv0-platform/src/ingestion/types.ts

export type NormalizedEdgeType =
| "OWNED_BY"
// ... existing types ...
| "CALLS";

Impact: None. Existing graph data has no CALLS edges. The platform already accepts any edge type string in buildRelationships() (graph-transformer.ts line 60) — it does not validate against the type union at runtime. The type union is for TypeScript compile-time checking only.

A2. Connector emits CALLS edges

File: /Users/lucky/dev/securityv0/sv0-connectors/integrations/entra-servicenow/src/entra_servicenow/core/transformer.py

Modify _process_execution_chain() to emit CALLS edges for indirect callers instead of EXECUTES_ON. This is a connector-side change that produces different NormalizedGraph output. Old graph.json files without CALLS edges still ingest correctly — the platform does not require CALLS edges.

Backward compatibility: Full. Old graph.json payloads produce the same entities they always did. New payloads include CALLS edges that the platform stores as additional relationship entries on entity documents.

A3. Update subgraph traversal

File: /Users/lucky/dev/securityv0/sv0-platform/src/storage/mongo/adapter.ts

Add "CALLS" to the reverse types for non-automation seeds:

const reverseTypes = isAutomationSeed
? new Set(["AUTHENTICATES_TO"])
: new Set(["RUNS_AS", "AUTHENTICATES_TO", "CALLS"]);

Impact: Subgraph queries from identity seeds now return additional entities (BRs that call SIs that RUNS_AS the seeded SP). This is strictly additive — no existing results are removed.

A4. Increase MAX_DEPTH

File: /Users/lucky/dev/securityv0/sv0-platform/src/api/routes/graph.ts

const MAX_DEPTH = 4;  // was 3

Impact: API consumers that request depth=4 now get results instead of being clamped to 3. Default depth remains 2. No breaking change.

A5. Update UI layout layers

File: /Users/lucky/dev/securityv0/sv0-platform/ui/src/components/graph/layout.ts

Replace the current executionLayer() with the 9-layer version described in section 4. Add "CALLS" to the edge style map with a distinctive style (solid, teal, arrowhead).

Impact: Visual change only. Same nodes and edges, different positions. No API change.

A6. Update UI node labels

File: /Users/lucky/dev/securityv0/sv0-platform/ui/src/components/graph/EntityNode.tsx

Pass identitySubtype through GraphNodeData and render it as the type label for identity entities.

Impact: Visual change only.

Phase B: Semantic Correction (Minor Connector Breaking Change)

This changes the meaning of edges on automation entities.

B1. Remove incorrect RUNS_AS and EXECUTES_ON from indirect callers

When a BR calls an SI that calls a REST Message:

  • Before: BR gets RUNS_AS -> SP and EXECUTES_ON -> REST Message.
  • After: BR gets CALLS -> SI. Only the SI gets RUNS_AS -> SP and EXECUTES_ON -> REST Message.

This is a connector-side change. Existing platform data with the old edges is not modified — the platform is append/upsert, not delete. On re-ingest with the new connector, the entity's relationships array is replaced wholesale (upsert replaces $set on the relationships field, per adapter.ts line 57).

Migration concern: If someone queries for "all entities with RUNS_AS -> this SP" they will get fewer results after re-ingest (BRs that indirectly call SIs will no longer appear). The query accessible-by in the platform uses the accessible_by computed field, not raw RUNS_AS edges, so the path materializer's output is what matters.

Path materializer impact: The materializer follows RUNS_AS edges from identities. After Phase B, a BR no longer has RUNS_AS -> SP. Instead:

  • BR has CALLS -> SI
  • SI has RUNS_AS -> SP

The path materializer does NOT follow CALLS edges today. It follows RUNS_AS to borrow target identity paths. For the BR to still get execution paths, the materializer must be updated to follow CALLS edges (treating them like RUNS_AS in terms of path delegation):

// In computePathsForIdentity(), after RUNS_AS handling:

// Code invocation: follow CALLS edges to get paths from invoked artifacts
const callsTargetIds = identity.relationships
.filter(r => r.type === "CALLS")
.map(r => r.target_id);

for (const targetId of callsTargetIds) {
if (visited.has(targetId)) continue;
const targetIdentity = await storageAdapter.getEntity(tenantId, targetId);
if (!targetIdentity || targetIdentity.entity_type !== "identity") continue;

const targetPaths = await computePathsForIdentity(
targetIdentity,
tenantId,
storageAdapter,
depth, // CALLS does not consume depth, like RUNS_AS
visited
);

for (const path of targetPaths) {
paths.push({
...path,
via_identity: targetIdentity._id
});
}
}

This is the key insight: Phase B is only safe to deploy if the path materializer is updated simultaneously. Otherwise, BRs lose their execution paths on re-ingest.

Phase C: Future Enhancements (Not This Iteration)

  • WRITES_TO / MUTATES edges for script mutation analysis
  • provenance_chain subgraph mode
  • Trigger instance evidence nodes
  • HTTP method metadata on EXECUTES_ON edge properties

6. The Identity Question

The Problem

A Script Include (ServiceNow code artifact) and a Service Principal (Azure cloud identity) are both entity_type: "identity" in the platform. They are fundamentally different things:

AspectScript IncludeService Principal
What it isCode artifactCloud identity
Can it authenticate?No (uses another identity's session)Yes (has credentials)
Does it have credentials?NoYes (client secret, certificate)
Does it appear in sign-in logs?NoYes
Does it have roles directly?No (inherits via RUNS_AS)Yes (HAS_ROLE)
Can it be disabled?Yes (active=false)Yes (accountEnabled=false)
Source systemServiceNowEntra ID
What fires it?Another automation (CALLS)API call, credential presentation

The Resolution

Do not change the entity type. Use the subtype system that already exists.

The platform's entity_type: "identity" is intentionally broad. It means: "this entity participates in execution path analysis as a subject that can (directly or transitively) act on resources." Both a Script Include and a Service Principal fit this definition:

  • A Service Principal acts on resources directly (HAS_ROLE -> GRANTS -> APPLIES_TO).
  • A Script Include acts on resources transitively (CALLS -> SI -> RUNS_AS -> SP -> HAS_ROLE -> ...) or (EXECUTES_ON -> REST Message).

The identitySubtype discriminator is the correct layer for distinguishing them. The existing subtypes already capture the taxonomy:

identitySubtypeNatureHow it gets authority
service_principalCloud identityDirect: HAS_ROLE
oauth_appAuth infrastructureDirect: linked to SP via AUTHENTICATES_TO
business_ruleTrigger automationTransitive: CALLS -> SI or RUNS_AS -> SP
system_executionCode artifactTransitive: RUNS_AS -> SP
scheduled_jobCron automationDirect or transitive: RUNS_AS -> SP
flow_designer_flowLow-code automationDirect or transitive: RUNS_AS -> SP/user

What needs to change is how the platform and UI treat subtypes, not the entity type itself:

  1. UI rendering: Show identitySubtype as the primary type label, not entity_type. A Business Rule should say "Business Rule" not "identity".

  2. Color coding: Give automation subtypes their own visual treatment (e.g., teal/cyan) distinct from cloud identities (blue) and OAuth apps (indigo).

  3. Evaluator rules: Finding rules should distinguish between subtypes. dormant_authority on a Service Principal means "has permissions but no sign-ins." On a Business Rule, it means "has RUNS_AS -> SP with permissions but no trigger evidence."

  4. Graph layout: Separate automation subtypes into trigger-layer vs. code-layer as described in section 4.

  5. API filtering: The entity query API already supports ?identitySubtype=business_rule,system_execution filtering (adapter.ts lines 91-97). The UI should expose this as a first-class filter.

Why Not Create entity_type: "automation"?

If automation were a separate entity type:

  • The path materializer would need entity_type !== "identity" && entity_type !== "automation" guards everywhere.
  • The RUNS_AS relationship would cross entity type boundaries (automation -> identity), requiring special handling in every traversal.
  • The evaluator rules would need duplication for identity findings vs. automation findings.
  • The storage indexes on entity_type would need updating.
  • The existing ENTITY_TYPES const in types.ts and every switch/case on entity_type would need a new branch.

All of this complexity buys nothing that identitySubtype filtering does not already provide. The entity type is a storage-level discriminator. The subtype is a semantic discriminator. Use each at the right layer.


7. Implementation Priority and Ordering

Dependency Graph

A1 (add CALLS to types) ─┬─> A2 (connector emits CALLS)

├─> A3 (subgraph traversal)

└─> A5 (UI layout)

A4 (increase MAX_DEPTH) ──> independent, deploy anytime

A6 (UI node labels) ──────> independent, deploy anytime

B1 (remove incorrect edges) ──> depends on A2 AND path materializer update
  1. A1 + A6 (1 hour): Add CALLS type, fix UI labels. Zero-risk, immediate visual improvement.
  2. A4 + A5 (2 hours): Increase depth, update layout layers. Visual improvement for existing data.
  3. A2 + A3 (4 hours): Connector emits CALLS, platform traversal updated. Re-ingest to see CALLS edges.
  4. B1 (4 hours): Semantic correction of indirect caller edges + path materializer update. Requires re-ingest and regression testing of execution paths.

Test Plan

  • Unit tests for CALLS edge emission: Verify that indirect BRs get CALLS -> SI, not EXECUTES_ON -> REST Message. Extend test_transformer.py.
  • Integration test for subgraph traversal: Seed data with a BR -> CALLS -> SI -> EXECUTES_ON -> REST -> AUTHENTICATES_VIA -> OAuth chain. Verify execution_flow BFS from BR seed reaches all nodes. Verify BFS from SP seed reaches BR via reverse CALLS.
  • Path materializer regression: After B1, verify that a BR with CALLS -> SI still gets execution_paths computed (via CALLS -> SI -> RUNS_AS -> SP -> HAS_ROLE -> ... chain).
  • UI snapshot test: Verify that the new layer assignment produces a visually correct left-to-right chain for the AzureGraphRouter case.

8. Summary of Recommendations

#ChangeScopeBackward-CompatiblePriority
1Keep entity_type: identity, use identitySubtype for displayUIYesP0
2Add CALLS relationship typePlatform typesYesP0
3Connector emits CALLS edges for BR -> SI invocationsConnectorYesP1
4Remove EXECUTES_ON and RUNS_AS from indirect BRsConnectorMinor breakingP1
5Update path materializer to follow CALLS edgesPlatformYesP1 (must ship with #4)
6Add CALLS to execution_flow reverse lookupPlatform APIYesP1
7Increase MAX_DEPTH to 4Platform APIYesP2
8Split automation layer into trigger/code sublayersUIYesP2
9Store mutation data as node propertiesConnectorYesP3
10Do NOT model trigger records as topology nodesDecisionN/ADecision
11Do NOT model record changes as topology nodes (yet)DecisionN/ADecision
12Do NOT add entity_type: automationDecisionN/ADecision

Appendix A: Full Provenance Chain After Changes

After implementing phases A and B, the AzureGraphRouter execution chain in the graph looks like this:

Nodes:
sn-table-incident (resource, trigger) Layer 0
sn-br-aa5e60... (identity, business_rule) Layer 1
sn-si-002e68... (identity, system_execution) Layer 2
sn-restmsg-8ccf0d... (resource, rest_message) Layer 3
sn-oauth-... (identity, oauth_app) Layer 4
entra-sp-0be765... (identity, service_principal) Layer 5
sn-user-admin (owner, human) Layer 0

Edges:
BR -> TRIGGERS_ON -> incident table
BR -> CALLS -> SI: AzureGraphRouter
BR -> OWNED_BY -> admin user
SI -> EXECUTES_ON -> REST Message: Graph - sn-ticket-router
SI -> RUNS_AS -> SP: sn-ticket-router
SI -> OWNED_BY -> admin user
REST Message -> AUTHENTICATES_VIA -> OAuth: Azure Graph OAuth Client
SP -> AUTHENTICATES_TO -> OAuth: Azure Graph OAuth Client

Execution path (computed by path materializer):
BR -> (via CALLS) -> SI -> (via RUNS_AS) -> SP -> HAS_ROLE -> ... -> Resource

Appendix B: Files to Modify

FileChange
sv0-platform/src/domain/graph/relationship-types.tsAdd "CALLS"
sv0-platform/src/ingestion/types.tsAdd "CALLS" to NormalizedEdgeType
sv0-platform/src/ingestion/path-materializer.tsFollow CALLS edges like RUNS_AS
sv0-platform/src/storage/mongo/adapter.tsAdd "CALLS" to reverse types for identity seeds
sv0-platform/src/api/routes/graph.tsChange MAX_DEPTH from 3 to 4
sv0-platform/ui/src/components/graph/layout.tsNew 9-layer assignment, CALLS edge style
sv0-platform/ui/src/components/graph/EntityNode.tsxShow identitySubtype as label
sv0-connectors/.../transformer.pyEmit CALLS edges, remove incorrect EXECUTES_ON/RUNS_AS from indirect callers
sv0-connectors/.../test_transformer.pyAdd CALLS edge tests