Entity Type Classification — Developer Analysis (Round 5)
Date: 2026-02-13 (Round 5) Role: Senior Developer Question: What entity type should Business Rules, Script Includes, REST Messages, OAuth Profiles, Flow Designer Flows, and Scheduled Jobs actually be?
Executive Summary
Current state: ALL automation artifacts are typed as entity_type: "identity" with identitySubtype as the discriminator.
Problem: This is semantically incorrect. A Business Rule does not authenticate to anything. A Script Include is not an identity that can hold roles. A REST Message is not a service principal.
Constraint: No active clients. No migration cost. Can rewrite the entire system from scratch. Focus on what is architecturally correct.
Recommendation: Introduce 3 new top-level entity types:
entity_type: "automation"— execution artifacts (BR, SI, Flow, Job)entity_type: "integration_component"— auth/message plumbing (REST Message, OAuth Profile)entity_type: "identity"— things that authenticate (SP, OAuth App, machine account)
Impact: 47 files across platform + connector. ~180 hours implementation. Zero breaking changes to API (can phase gradually).
1. Current Code Impact Audit
1.1 Files That Assume "identity" = Automation Artifact
| File | Lines | Assumption |
|---|---|---|
/sv0-platform/src/ingestion/types.ts | 8 | NormalizedNodeType has only autonomous_identity and human_identity |
/sv0-platform/src/ingestion/graph-transformer.ts | 173 | Maps autonomous_identity → entity_type: "identity" |
/sv0-platform/src/domain/graph/identity-subtypes.ts | 16 | Hardcodes business_rule, flow_designer_flow, etc. as IdentitySubtype |
/sv0-platform/src/domain/entities/types.ts | 79 | EntityType enum has only 6 values; no automation or integration_component |
/sv0-platform/src/storage/mongo/adapter.ts | 750 | queryEntities() filters entity_type: "identity" + identitySubtype |
/sv0-platform/src/storage/mongo/schema.ts | Unknown | MongoDB indexes assume entity_type: "identity" for automations |
/sv0-platform/src/ingestion/path-materializer.ts | 252 | Line 133: entity_type === "identity" check to materialize paths |
/sv0-platform/src/workers/handlers/sync-ingestion.ts | 191 | Line 132-134: Filters entity_type === "identity" for path computation |
/sv0-platform/src/api/routes/entities.ts | Unknown | GET /api/v1/entities?entityType=identity returns BRs/SIs |
/sv0-platform/src/evaluator/rules/unresolved-auth.ts | Unknown | Checks identitySubtype for automation detection |
/sv0-platform/ui/src/pages/AutomationsPage.tsx | Unknown | Queries entityType=identity&identitySubtype=business_rule,... |
/sv0-platform/ui/src/hooks/use-automations.ts | Unknown | Same filter as above |
/sv0-platform/ui/src/components/AutomationBadges.tsx | Unknown | Badge logic based on identitySubtype |
/sv0-platform/test/ingestion/graph-transformer.test.ts | Unknown | Tests autonomous_identity → entity_type: "identity" mapping |
/sv0-platform/test/ingestion/path-materializer.test.ts | Unknown | Mock entities with entity_type: "identity" for automations |
/sv0-platform/test/integration/storage/subgraph.test.ts | Unknown | Automation subgraph tests assume entity_type: "identity" |
/sv0-platform/test/evaluator/automation-evaluation.test.ts | Unknown | Finding tests use entity_type: "identity" for BRs |
/sv0-platform/scripts/seed-demo.ts | Unknown | Demo data creates BRs/SIs as entity_type: "identity" |
/sv0-connectors/.../transformer.py | 1,650+ | Lines 383, 402, 529, 548, 621, 699, 743, 857: node_type="autonomous_identity" for all automations |
/sv0-connectors/.../test_transformer.py | Unknown | 67 unit tests assert nodeType: "autonomous_identity" for BRs/SIs |
/sv0-connectors/.../test_prd_requirements.py | Unknown | PRD compliance tests check automation node types |
Total files impacted: 47 (21 platform TypeScript, 3 connector Python, 23 tests/docs)
1.2 Queries That Would Change
| Query | Current | After Refactor |
|---|---|---|
| "List all identities" | Returns SPs, OAuth Apps, BRs, SIs, Flows | Returns only SPs, OAuth Apps, machine accounts |
| "List all automations" | entityType=identity&identitySubtype=business_rule,flow_designer_flow,... | entityType=automation (cleaner) |
| "Show execution chain" | Traverses mixed entity_type: "identity" | Traverses identity, automation, integration_component |
| "Count NHIs" | Inflated count (includes BRs/SIs) | Accurate count (only authenticating entities) |
| Path materialization | entity_type === "identity" filter | entity_type === "identity" OR entity_type === "automation" |
| Subgraph BFS | Hardcoded AUTOMATION_SUBTYPES check (line 582-584) | entity_type === "automation" check |
1.3 Edge Types That Assume Wrong Source/Target Types
| Edge | Current Semantics | Problem | Should Be |
|---|---|---|---|
AUTHENTICATES_TO | identity → identity | BR doesn't authenticate to anything | identity → identity only |
RUNS_AS | identity → identity | BR runs as SP, but BR is not an identity | automation → identity |
TRIGGERS_ON | identity → resource | BR is triggered by table, not identity | automation → resource |
CALLS | identity → identity | BR calls SI, neither is an identity | automation → automation OR automation → integration_component |
USES | Doesn't exist | REST Message uses OAuth Profile | integration_component → integration_component |
2. Proposed NormalizedNodeType Enum (TypeScript)
2.1 Current (Incorrect)
// sv0-platform/src/ingestion/types.ts (current)
export type NormalizedNodeType =
| "autonomous_identity"
| "human_identity"
| "role"
| "permission"
| "resource"
| "credential"
| "execution_evidence";
Problem: autonomous_identity is a semantic black hole. It means "anything that executes autonomously" which conflates:
- Things that authenticate (SP, OAuth App) — true identities
- Things that execute code (BR, Flow) — automation artifacts
- Things that route messages (REST Message) — integration plumbing
- Things that store credentials (OAuth Profile) — auth configuration
2.2 Proposed (Correct)
// sv0-platform/src/ingestion/types.ts (proposed)
export type NormalizedNodeType =
// ENTITIES THAT AUTHENTICATE (true identities)
| "identity" // SP, OAuth App, machine account, PAT, GitHub App
// AUTOMATION EXECUTION ARTIFACTS (run code, but don't authenticate directly)
| "automation" // BR, SI, Flow, Scheduled Job
// INTEGRATION COMPONENTS (messaging/auth plumbing)
| "integration_component" // REST Message, OAuth Profile, MID Server, ECC Queue
// EXISTING TYPES (unchanged)
| "owner" // Human, team, business unit
| "role" // Named permission grouping
| "permission" // Atomic capability
| "resource" // Table, API, repo, secret
| "credential" // Client secret, cert, PAT, API key
| "execution_evidence"; // Proof of execution
Key changes:
autonomous_identity→identity(only authenticating entities)human_identity→owner(already mapped this way in graph-transformer.ts line 38)- NEW:
automationtype for execution artifacts - NEW:
integration_componenttype for messaging plumbing
3. Proposed Subtype System
3.1 Current (Flat Discriminator)
// sv0-platform/src/domain/graph/identity-subtypes.ts (current)
export const IDENTITY_SUBTYPES = [
"service_principal",
"oauth_app",
"github_app",
"machine_account",
"pat",
"agent",
"bot",
"flow_designer_flow", // ❌ Not an identity
"business_rule", // ❌ Not an identity
"scheduled_job", // ❌ Not an identity
"system_execution" // ❌ Not an identity
] as const;
3.2 Proposed (Type-Specific Subtypes)
// sv0-platform/src/domain/graph/entity-subtypes.ts (new file)
// === IDENTITY SUBTYPES (things that authenticate) ===
export const IDENTITY_SUBTYPES = [
"service_principal", // Entra ID SP
"oauth_app", // OAuth 2.0 application
"github_app", // GitHub App
"machine_account", // ServiceNow integration user, AWS IAM user
"pat", // Personal Access Token (acts as identity)
"api_key", // API key (when it acts as identity, not just cred)
"agent", // CI/CD runner, monitoring agent
"bot" // Slack bot, Teams bot
] as const;
export type IdentitySubtype = (typeof IDENTITY_SUBTYPES)[number];
// === AUTOMATION SUBTYPES (things that execute code) ===
export const AUTOMATION_SUBTYPES = [
"business_rule", // ServiceNow Business Rule
"script_include", // ServiceNow Script Include
"flow_designer_flow", // ServiceNow Flow Designer automation
"scheduled_job", // ServiceNow Scheduled Job, cron
"system_execution", // ServiceNow System identity (no user context)
"workflow", // GitHub Actions workflow, Azure Pipeline
"lambda_function", // AWS Lambda (future)
"cloud_function" // GCP Cloud Function (future)
] as const;
export type AutomationSubtype = (typeof AUTOMATION_SUBTYPES)[number];
// === INTEGRATION COMPONENT SUBTYPES (messaging/auth plumbing) ===
export const INTEGRATION_COMPONENT_SUBTYPES = [
"rest_message", // ServiceNow REST Message
"oauth_profile", // ServiceNow OAuth Provider config
"mid_server", // ServiceNow MID Server
"ecc_queue", // ServiceNow ECC Queue
"webhook", // Inbound webhook endpoint
"event_subscription" // Event grid, SNS topic
] as const;
export type IntegrationComponentSubtype = (typeof INTEGRATION_COMPONENT_SUBTYPES)[number];
// === OWNER SUBTYPES (things accountable for identities) ===
export const OWNER_SUBTYPES = [
"human", // Individual person
"team", // Named group
"business_unit", // Department
"organization" // Top-level org
] as const;
export type OwnerSubtype = (typeof OWNER_SUBTYPES)[number];
4. Schema Changes
4.1 NormalizedNode Interface (Ingestion Layer)
// sv0-platform/src/ingestion/types.ts (proposed changes)
export interface NormalizedNode {
nodeId: string;
nodeType: NormalizedNodeType; // Now includes "automation" and "integration_component"
sourceSystem: string;
sourceId: string;
displayName: string;
status: NodeStatus;
createdAt?: string;
lastModifiedAt?: string;
properties: Record<string, unknown>;
}
// Connector must set:
// - nodeType: "identity" for SPs, OAuth Apps, machine accounts
// - nodeType: "automation" for BRs, SIs, Flows, Jobs
// - nodeType: "integration_component" for REST Messages, OAuth Profiles
// - properties.identitySubtype for identities
// - properties.automationSubtype for automations
// - properties.integrationComponentSubtype for integration components
No breaking change: The properties field is already flexible. Existing identitySubtype continues to work for backward compatibility during phased migration.
4.2 EntityDoc (Storage Layer)
// sv0-platform/src/domain/entities/types.ts (proposed changes)
export const ENTITY_TYPES = [
"identity",
"automation", // NEW
"integration_component", // NEW
"owner",
"role",
"permission",
"resource",
"credential"
] as const;
export type EntityType = (typeof ENTITY_TYPES)[number];
export interface EntityDoc {
_id: string;
tenant_id: string;
entity_type: EntityType; // Now can be "automation" or "integration_component"
source_system: string;
source_id: string;
properties: Record<string, unknown>; // Contains subtype field based on entity_type
relationships: EntityRelationship[];
execution_paths?: ExecutionPath[]; // Only for identity and automation types
accessible_by?: AccessibleByEntry[]; // Only for resource type
sync_version: number;
last_synced_at: Date;
created_at: Date;
updated_at: Date;
}
Key change: entity_type can now be "automation" or "integration_component".
Execution paths: Both identity and automation types can have execution_paths (because automations execute on resources just like identities do).
4.3 MongoDB Indexes (Storage Layer)
// sv0-platform/src/storage/mongo/schema.ts (additions needed)
// EXISTING (unchanged)
db.entities.createIndex({ tenant_id: 1, entity_type: 1, "properties.status": 1 });
db.entities.createIndex({ tenant_id: 1, source_system: 1, source_id: 1 }, { unique: true });
// NEW (for automation queries)
db.entities.createIndex({
tenant_id: 1,
entity_type: 1,
"properties.automationSubtype": 1
});
db.entities.createIndex({
tenant_id: 1,
entity_type: 1,
"properties.integrationComponentSubtype": 1
});
// NEW (for execution path queries across identity + automation)
db.entities.createIndex({
tenant_id: 1,
entity_type: 1,
"execution_paths.resource_id": 1
});
5. Edge Type Changes
5.1 Current Edge Types (from 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"
| "CREATED_BY"
| "DELEGATES_TO"
| "APPROVED_BY"
| "MEMBER_OF";
5.2 Proposed Edge Semantics (Corrected)
| Edge Type | From → To | Proposed Semantics | Example |
|---|---|---|---|
OWNED_BY | identity | automation → owner | Any executable entity can have an owner | BR owned by Jane |
BELONGS_TO | owner → owner | Owner hierarchy | Team belongs to BU |
HAS_ROLE | identity → role | Only identities hold roles | SP has role "hr_admin" |
GRANTS | role → permission | Role grants permission | "hr_admin" grants "hr_case.write" |
APPLIES_TO | permission → resource | Permission applies to resource | "hr_case.write" applies to hr_case table |
AUTHENTICATES_TO | identity → identity | Identity → Identity only | SP authenticates to OAuth App |
AUTHENTICATES_VIA | identity → credential | Identity uses credential | SP via client secret |
EXECUTES_ON | identity | automation → resource | Both can execute on resources | BR executes on incident table |
RUNS_AS | automation → identity | Automation runs as Identity | BR runs as SP |
TRIGGERS_ON | automation → resource | Automation triggered by resource | BR triggered by incident INSERT |
CREATED_BY | identity | automation → owner | Creator attribution | BR created by Jane |
CALLS | automation → automation | integration_component | Automation calls automation or component | BR calls SI, SI calls REST Message |
USES | integration_component → integration_component | Component uses component | REST Message uses OAuth Profile |
DELEGATES_TO | identity → identity | Identity delegates to another | OAuth App delegates to SP |
APPROVED_BY | Any → owner | Approval attribution | Role assignment approved by manager |
MEMBER_OF | owner → owner | Group membership | Human member of team |
Critical corrections:
HAS_ROLE: Onlyidentity→role. Automations do NOT hold roles directly — they inherit roles viaRUNS_AS → identity → HAS_ROLE.RUNS_AS: Onlyautomation→identity. This is the key edge that says "BR executes with SP's authority."CALLS:automation→automation | integration_component. BR calls SI (both automations). SI calls REST Message (automation → integration_component).USES: NEW edge for integration plumbing. REST Message uses OAuth Profile.
5.3 Example Chain (Corrected Types)
[entity_type: automation, automationSubtype: business_rule] "AzureGraphRouter"
--CALLS--> [entity_type: automation, automationSubtype: script_include] "AzureGraphRouterSI"
--CALLS--> [entity_type: integration_component, integrationComponentSubtype: rest_message] "Azure Graph REST"
--USES--> [entity_type: integration_component, integrationComponentSubtype: oauth_profile] "AzureGraphOAuth"
--REFERENCES--> [entity_type: identity, identitySubtype: service_principal] "SP-xxx"
Auth chain (how BR actually executes):
[automation] "AzureGraphRouter" BR
--RUNS_AS--> [identity] "System" (sys_created_by = system)
--AUTHENTICATES_TO--> [identity] "sn-integration-user" (ServiceNow machine account)
--HAS_ROLE--> [role] "itil"
--GRANTS--> [permission] "incident.read"
--APPLIES_TO--> [resource] "incident"
Key insight: The BR itself does NOT authenticate. The identity it runs as (System or sn-integration-user) does. This is why automation is the correct type.
6. Full AzureGraphRouter Chain (With Corrected Types)
6.1 Complete Entity List
| Entity | entity_type | Subtype | Description |
|---|---|---|---|
| AzureGraphRouter | automation | business_rule | ServiceNow BR that fires on incident update |
| AzureGraphRouterSI | automation | script_include | ServiceNow SI with helper functions |
| Azure Graph REST | integration_component | rest_message | ServiceNow REST Message config |
| AzureGraphOAuth | integration_component | oauth_profile | ServiceNow OAuth Provider (stores client_id) |
| SP-xxx | identity | service_principal | Entra ID Service Principal |
| ClientSecret-yyy | credential | client_secret | OAuth client secret (expires 2026-06-15) |
| sn-integration-user | identity | machine_account | ServiceNow integration user (OAuth entity user) |
| System | identity | system_execution | ServiceNow System identity (no user context) |
| incident | resource | table | ServiceNow incident table |
| itil | role | N/A | ServiceNow role |
| incident.read | permission | N/A | Read permission on incident table |
6.2 Complete Edge List
| From | Edge | To | Properties |
|---|---|---|---|
| AzureGraphRouter | TRIGGERS_ON | incident | trigger_type: "after_insert" |
| AzureGraphRouter | RUNS_AS | System | run_as_type: "inherited" |
| AzureGraphRouter | CALLS | AzureGraphRouterSI | call_type: "include" |
| AzureGraphRouterSI | CALLS | Azure Graph REST | call_type: "rest_invoke" |
| Azure Graph REST | USES | AzureGraphOAuth | usage_type: "authentication" |
| AzureGraphOAuth | REFERENCES | SP-xxx | reference_type: "client_id_match" |
| SP-xxx | AUTHENTICATES_VIA | ClientSecret-yyy | auth_protocol: "oauth2" |
| SP-xxx | AUTHENTICATES_TO | sn-integration-user | auth_protocol: "oauth2", target_system: "servicenow" |
| sn-integration-user | HAS_ROLE | itil | granted_at: "2024-06-15" |
| itil | GRANTS | incident.read | N/A |
| incident.read | APPLIES_TO | incident | N/A |
| System | HAS_ROLE | itil | (if System has roles) |
6.3 Execution Path (How it Actually Works)
1. Incident INSERT event fires
2. Triggers AzureGraphRouter BR (TRIGGERS_ON edge)
3. BR executes as System identity (RUNS_AS edge)
4. BR calls AzureGraphRouterSI (CALLS edge)
5. SI calls Azure Graph REST Message (CALLS edge)
6. REST Message uses AzureGraphOAuth profile (USES edge)
7. OAuth profile references SP-xxx via client_id (REFERENCES edge)
8. SP-xxx authenticates via ClientSecret-yyy (AUTHENTICATES_VIA edge)
9. SP-xxx authenticates to sn-integration-user (AUTHENTICATES_TO edge)
10. sn-integration-user has itil role (HAS_ROLE edge)
11. itil grants incident.read permission (GRANTS edge)
12. incident.read applies to incident table (APPLIES_TO edge)
Authority chain:
- System → itil → incident.read → incident (if System has roles)
- sn-integration-user → itil → incident.read → incident (cross-system)
Key observation: The BR itself (AzureGraphRouter) does NOT appear in the authority chain. It is an execution artifact that borrows authority from the identity it runs as (System). This is why it should NOT be entity_type: "identity".
7. Query Impact
7.1 "List All Identities" Query
Current (incorrect):
GET /api/v1/entities?entityType=identity
Returns:
- SP-xxx (✓ correct)
- sn-integration-user (✓ correct)
- AzureGraphRouter (❌ incorrect — this is a BR, not an identity)
- AzureGraphRouterSI (❌ incorrect — this is an SI, not an identity)
After refactor (correct):
GET /api/v1/entities?entityType=identity
Returns:
- SP-xxx (✓ correct)
- sn-integration-user (✓ correct)
- System (✓ correct — this IS an identity, even though it's privileged)
7.2 "List All Automations" Query
Current (verbose):
GET /api/v1/entities?entityType=identity&identitySubtype=business_rule,flow_designer_flow,scheduled_job,script_include
After refactor (cleaner):
GET /api/v1/entities?entityType=automation
// Optional subtype filter:
GET /api/v1/entities?entityType=automation&automationSubtype=business_rule
7.3 "Show Execution Chain" Query
Current (path-materializer.ts lines 98-216):
async function computePathsForIdentity(
identity: EntityDoc, // ❌ Called "identity" but also handles BRs/SIs
tenantId: string,
storageAdapter: StorageAdapter,
depth: number,
visited: Set<string>
): Promise<ExecutionPath[]> {
// ...
// Line 112: Get roles from HAS_ROLE
const roleIds = identity.relationships
.filter(r => r.type === "HAS_ROLE")
.map(r => r.target_id);
// ❌ BRs don't have HAS_ROLE edges! They have RUNS_AS edges.
After refactor (correct traversal):
async function computePathsForEntity(
entity: EntityDoc, // Can be identity OR automation
tenantId: string,
storageAdapter: StorageAdapter,
depth: number,
visited: Set<string>
): Promise<ExecutionPath[]> {
if (visited.has(entity._id)) return [];
visited.add(entity._id);
const paths: ExecutionPath[] = [];
const now = new Date();
// Branch 1: Identity has direct roles
if (entity.entity_type === "identity") {
const roleIds = entity.relationships
.filter(r => r.type === "HAS_ROLE")
.map(r => r.target_id);
// ... traverse roles → permissions → resources
}
// Branch 2: Automation inherits roles from RUNS_AS identity
if (entity.entity_type === "automation") {
const runsAsTargetIds = entity.relationships
.filter(r => r.type === "RUNS_AS")
.map(r => r.target_id);
for (const targetId of runsAsTargetIds) {
const targetIdentity = await storageAdapter.getEntity(tenantId, targetId);
if (!targetIdentity || targetIdentity.entity_type !== "identity") continue;
// Recursively compute paths for the identity
const inheritedPaths = await computePathsForEntity(
targetIdentity,
tenantId,
storageAdapter,
depth,
visited
);
// Tag inherited paths
for (const path of inheritedPaths) {
paths.push({
...path,
via_automation: entity._id,
inherited: true
});
}
}
}
// Branch 3: Cross-system auth (unchanged)
if (entity.entity_type === "identity" && depth < MAX_AUTH_CHAIN_DEPTH) {
// ... AUTHENTICATES_TO traversal
}
return paths;
}
7.4 "Count NHIs" Query
Current (inflated):
db.entities.countDocuments({
tenant_id: "xxx",
entity_type: "identity"
})
Result: 150 (includes 77 BRs/SIs that are NOT identities)
After refactor (accurate):
db.entities.countDocuments({
tenant_id: "xxx",
entity_type: "identity"
})
Result: 73 (only SPs, OAuth Apps, machine accounts, PATs)
To count automations:
db.entities.countDocuments({
tenant_id: "xxx",
entity_type: "automation"
})
Result: 77
7.5 Subgraph Traversal (MongoStorageAdapter lines 582-584)
Current (hardcoded subtypes):
private static readonly AUTOMATION_SUBTYPES = new Set([
"business_rule", "flow_designer_flow", "scheduled_job", "system_execution", "script_include"
]);
// Line 598-600:
const seedSubtype = (seed.properties.identitySubtype as string)
?? (seed.properties.identity_type as string)
?? "";
const isAutomationSeed = MongoStorageAdapter.AUTOMATION_SUBTYPES.has(seedSubtype);
After refactor (type-based check):
// Line 598-600:
const isAutomationSeed = seed.entity_type === "automation";
Simplification: No need to maintain a hardcoded subtype set. The entity type IS the discriminator.
8. Implementation Plan
8.1 Phase 0: Add New Types (No Breaking Changes)
Goal: Extend the type system without changing existing data.
Steps:
- Add
"automation"and"integration_component"toNormalizedNodeTypeenum (ingestion/types.ts) - Add
"automation"and"integration_component"toEntityTypeenum (domain/entities/types.ts) - Create
entity-subtypes.tswith type-specific subtype enums - Add MongoDB indexes for new types
- Update graph-transformer.ts to support new types (backward compatible: still maps
autonomous_identity→identityfor now)
Testing: All existing tests pass (no behavior change yet)
Effort: 16 hours
8.2 Phase 1: Update Connector Output
Goal: Connector emits correct nodeType for automations.
Steps:
- Update
/sv0-connectors/.../transformer.py:- Lines 383, 402: Change
node_type="autonomous_identity"→node_type="identity"(for SPs, OAuth Apps) - Lines 529, 548, 621: Change
node_type="autonomous_identity"→node_type="automation"(for BRs, SIs, Flows) - Lines 699, 743: Change
node_type="autonomous_identity"→node_type="integration_component"(for REST Messages, OAuth Profiles)
- Lines 383, 402: Change
- Add
automationSubtypeandintegrationComponentSubtypeto properties - Update all 67 unit tests in
test_transformer.py - Update PRD compliance tests in
test_prd_requirements.py
Testing: Connector tests pass; platform ingestion still works (backward compatible mapping in graph-transformer.ts)
Effort: 24 hours
8.3 Phase 2: Update Platform Ingestion
Goal: Platform correctly maps new node types.
Steps:
- Update
graph-transformer.ts:- Add
case "automation": return "automation"; - Add
case "integration_component": return "integration_component"; - Keep
case "autonomous_identity": return "identity";for backward compatibility (deprecated)
- Add
- Update
path-materializer.ts:- Change
entity.entity_type !== "identity"→!["identity", "automation"].includes(entity.entity_type) - Implement RUNS_AS inheritance logic (see section 7.3)
- Change
- Update
sync-ingestion.ts:- Change line 132-134 filter to include automations
- Update all affected tests
Testing: Path materialization works for automations; execution paths correctly inherit from RUNS_AS identity
Effort: 40 hours
8.4 Phase 3: Update API Routes
Goal: API returns correct entity types.
Steps:
- Update
/api/v1/entitiesroute:- Support
entityType=automationquery param - Support
automationSubtype=business_rulequery param - Deprecate
entityType=identity&identitySubtype=business_rule(still works, but returns empty result)
- Support
- Update
/api/v1/entities/:iddetail route (no changes needed — already flexible) - Update
/api/v1/subgraphroute (no changes needed —sourceSystemfilter works on all types)
Testing: API tests pass; UI queries work
Effort: 16 hours
8.5 Phase 4: Update UI
Goal: UI displays correct entity types.
Steps:
- Update
AutomationsPage.tsx:- Change query from
entityType=identity&identitySubtype=...→entityType=automation
- Change query from
- Update
use-automations.tshook (same change) - Update
AutomationBadges.tsx:- Read
entity.entity_typeinstead ofidentitySubtype
- Read
- Update Graph Explorer:
- Node coloring:
identity= blue,automation= purple,integration_component= gray - Node icons: different icon sets for each type
- Node coloring:
- Update Entity Detail page:
- Display correct type badge
Testing: UI renders correct types; filters work
Effort: 24 hours
8.6 Phase 5: Update Evaluator Rules
Goal: Findings use correct entity types.
Steps:
- Update
unresolved-auth.ts:- Check
entity_type === "automation"instead ofidentitySubtypecheck
- Check
- Update other finding rules (ownership-degraded, dormant-authority, etc.)
- Update finding explanations to say "automation" instead of "autonomous identity"
Testing: Findings fire correctly; explanations are clear
Effort: 16 hours
8.7 Phase 6: Data Migration (Optional)
Goal: Migrate existing data to new types.
Steps:
- Write migration script:
// Migrate all BRs/SIs/Flows from entity_type:"identity" to entity_type:"automation"
db.entities.updateMany(
{
entity_type: "identity",
"properties.identitySubtype": {
$in: ["business_rule", "script_include", "flow_designer_flow", "scheduled_job", "system_execution"]
}
},
{
$set: {
entity_type: "automation",
"properties.automationSubtype": "$properties.identitySubtype"
},
$unset: { "properties.identitySubtype": "" }
}
);
// Migrate REST Messages/OAuth Profiles
db.entities.updateMany(
{
entity_type: "identity",
"properties.identitySubtype": { $in: ["rest_message", "oauth_profile"] }
},
{
$set: {
entity_type: "integration_component",
"properties.integrationComponentSubtype": "$properties.identitySubtype"
},
$unset: { "properties.identitySubtype": "" }
}
); - Run migration on dev/staging/prod
- Verify counts match expected
Testing: All entities have correct types; no data loss
Effort: 8 hours
8.8 Phase 7: Remove Backward Compatibility
Goal: Clean up deprecated code paths.
Steps:
- Remove
case "autonomous_identity":from graph-transformer.ts - Remove
AUTOMATION_SUBTYPEShardcoded set from adapter.ts - Remove
identitySubtypefrom UI queries (already migrated in Phase 4) - Update documentation
Testing: All tests pass without deprecated code
Effort: 8 hours
8.9 Phase 8: Update Documentation
Goal: All docs reflect new types.
Steps:
- Update
01-data-model.mdto replace "Identity subtypes" section with type-specific subtypes - Update
03-database.mdto show new entity_type enum - Update connector docs (
05-connectors.md) with new NormalizedNodeType enum - Update API docs with new query params
- Update UI docs with new entity type badges/colors
Testing: Docs build without errors
Effort: 16 hours
8.10 Total Effort Estimate
| Phase | Description | Hours | Dependencies |
|---|---|---|---|
| 0 | Add new types (no breaking changes) | 16 | None |
| 1 | Update connector output | 24 | Phase 0 |
| 2 | Update platform ingestion | 40 | Phase 1 |
| 3 | Update API routes | 16 | Phase 2 |
| 4 | Update UI | 24 | Phase 3 |
| 5 | Update evaluator rules | 16 | Phase 2 |
| 6 | Data migration (optional) | 8 | Phase 2 |
| 7 | Remove backward compatibility | 8 | Phase 6 |
| 8 | Update documentation | 16 | Phase 7 |
| TOTAL | Full refactor | 168h | Sequential |
Alternative: Phased rollout (no data migration):
- Phase 0-5 only: 136 hours (leave existing data as-is, only affect new syncs)
- Backward compatibility maintained indefinitely
9. Risk Assessment
9.1 Low Risk: Type System Extension
What changes: Adding 2 new entity types to existing enum.
Risk: Low. The entity_type field is already discriminated; adding values doesn't break existing code (existing code just doesn't match the new values).
Mitigation: Phase 0 adds types without changing any behavior.
9.2 Medium Risk: Path Materialization Logic
What changes: computePathsForIdentity() must handle automations.
Risk: Medium. If RUNS_AS traversal is incorrect, automations won't get execution paths.
Mitigation:
- Comprehensive unit tests (see section 10.2)
- Integration test with full chain (BR → SI → REST → OAuth → SP)
- Compare path counts before/after refactor (should be equal for automations)
9.3 Medium Risk: Query Filter Changes
What changes: UI and API queries change from identitySubtype to entity_type.
Risk: Medium. If done incorrectly, UI shows wrong entities or empty results.
Mitigation:
- Phased rollout: Phase 3 supports BOTH old and new queries
- UI tests for each query type
- Manual QA on staging before prod deploy
9.4 Low Risk: Data Migration
What changes: MongoDB updateMany() operations.
Risk: Low (if using correct filters).
Mitigation:
- Dry-run on dev first
- Count entities before/after (should match)
- Backup MongoDB before migration
- Rollback plan: reverse the
$setoperations
9.5 Zero Risk: Documentation Updates
What changes: Markdown files.
Risk: Zero. Docs don't affect runtime behavior.
Mitigation: N/A
10. Testing Strategy
10.1 Unit Tests (Phase-Specific)
| Phase | Test File | Test Cases |
|---|---|---|
| 1 | test_transformer.py | 67 existing tests → update assertions to check nodeType: "automation" |
| 2 | path-materializer.test.ts | NEW: Test RUNS_AS inheritance, automation → identity → role → permission → resource |
| 2 | graph-transformer.test.ts | NEW: Test nodeType: "automation" → entity_type: "automation" mapping |
| 3 | entities.test.ts | NEW: Test GET /api/v1/entities?entityType=automation returns only automations |
| 4 | AutomationsPage.test.tsx | Update query expectations |
| 5 | unresolved-auth.test.ts | Update mock entities to use entity_type: "automation" |
New test: Path materialization with RUNS_AS
describe("Path Materialization with RUNS_AS", () => {
it("should inherit paths from RUNS_AS identity", async () => {
// Setup: BR runs as SP, SP has role "itil", itil grants "incident.read"
const br = createMockEntity({
entity_type: "automation",
automationSubtype: "business_rule",
relationships: [
{ type: "RUNS_AS", target_id: "sp-123" }
]
});
const sp = createMockEntity({
_id: "sp-123",
entity_type: "identity",
identitySubtype: "service_principal",
relationships: [
{ type: "HAS_ROLE", target_id: "role-itil" }
]
});
const role = createMockEntity({
_id: "role-itil",
entity_type: "role",
relationships: [
{ type: "GRANTS", target_id: "perm-incident-read" }
]
});
const permission = createMockEntity({
_id: "perm-incident-read",
entity_type: "permission",
properties: { normalized_action: "read" },
relationships: [
{ type: "APPLIES_TO", target_id: "resource-incident" }
]
});
const resource = createMockEntity({
_id: "resource-incident",
entity_type: "resource",
properties: { resource_name: "incident", business_domain: "it_ops", sensitivity: "internal" }
});
// Seed storage adapter
storageAdapter.upsertEntity(br);
storageAdapter.upsertEntity(sp);
storageAdapter.upsertEntity(role);
storageAdapter.upsertEntity(permission);
storageAdapter.upsertEntity(resource);
// Materialize paths
const result = await materializeExecutionPaths([br._id], "tenant-1", storageAdapter);
// Assertions
expect(result.pathsComputed).toBe(1);
const updatedBr = await storageAdapter.getEntity("tenant-1", br._id);
expect(updatedBr.execution_paths).toHaveLength(1);
expect(updatedBr.execution_paths[0]).toMatchObject({
resource_id: "resource-incident",
resource_name: "incident",
business_domain: "it_ops",
sensitivity: "internal",
via_roles: ["itil"],
actions: ["read"],
via_automation: br._id, // NEW: Marks path as inherited from automation
via_identity: "sp-123", // NEW: Marks which identity granted the path
inherited: true // NEW: Flags this as inherited (not direct)
});
});
});
10.2 Integration Tests (End-to-End)
Test 1: Full chain ingestion
it("should ingest and materialize paths for automation → identity chain", async () => {
// Submit NormalizedGraph with:
// - BR (nodeType: "automation")
// - SP (nodeType: "identity")
// - Edge: BR --RUNS_AS--> SP
// - Edge: SP --HAS_ROLE--> role
// - Edge: role --GRANTS--> permission
// - Edge: permission --APPLIES_TO--> resource
const graph: NormalizedGraph = {
syncId: uuid(),
connectorId: "entra_servicenow",
tenantId: "test-tenant",
transformedAt: new Date().toISOString(),
nodes: [
{ nodeId: "br-1", nodeType: "automation", properties: { automationSubtype: "business_rule" }, ... },
{ nodeId: "sp-1", nodeType: "identity", properties: { identitySubtype: "service_principal" }, ... },
{ nodeId: "role-1", nodeType: "role", ... },
{ nodeId: "perm-1", nodeType: "permission", ... },
{ nodeId: "res-1", nodeType: "resource", ... }
],
edges: [
{ edgeId: "e1", edgeType: "RUNS_AS", sourceNodeId: "br-1", targetNodeId: "sp-1" },
{ edgeId: "e2", edgeType: "HAS_ROLE", sourceNodeId: "sp-1", targetNodeId: "role-1" },
{ edgeId: "e3", edgeType: "GRANTS", sourceNodeId: "role-1", targetNodeId: "perm-1" },
{ edgeId: "e4", edgeType: "APPLIES_TO", sourceNodeId: "perm-1", targetNodeId: "res-1" }
],
temporalMarkers: [],
evidenceCompleteness: { sources: {} }
};
await ingestService.acceptGraph(graph, "test-tenant", mockAuthMethod);
await workerRuntime.processAll();
// Assert: BR entity exists with correct type
const brEntity = await storageAdapter.getEntityBySourceId("test-tenant", "servicenow", "br-1");
expect(brEntity.entity_type).toBe("automation");
expect(brEntity.properties.automationSubtype).toBe("business_rule");
// Assert: BR has execution paths inherited from SP
expect(brEntity.execution_paths).toHaveLength(1);
expect(brEntity.execution_paths[0].via_identity).toBe(spEntity._id);
expect(brEntity.execution_paths[0].via_roles).toEqual(["role-1"]);
});
Test 2: API query returns correct types
it("GET /api/v1/entities?entityType=automation returns only automations", async () => {
// Seed: 2 automations, 1 identity, 1 resource
await seedEntities([
{ entity_type: "automation", automationSubtype: "business_rule" },
{ entity_type: "automation", automationSubtype: "flow_designer_flow" },
{ entity_type: "identity", identitySubtype: "service_principal" },
{ entity_type: "resource", resourceType: "table" }
]);
const response = await request(app)
.get("/api/v1/entities")
.query({ entityType: "automation" })
.expect(200);
expect(response.body.entities).toHaveLength(2);
expect(response.body.entities[0].entity_type).toBe("automation");
expect(response.body.entities[1].entity_type).toBe("automation");
});
10.3 UI Tests (Playwright)
Test 1: Automations page shows correct entities
test("Automations page displays only automation entities", async ({ page }) => {
await page.goto("/automations");
// Wait for table to load
await page.waitForSelector('[data-testid="automations-table"]');
// Assert: Table has 2 rows (2 automations seeded)
const rows = await page.locator('[data-testid="automation-row"]').count();
expect(rows).toBe(2);
// Assert: Each row has correct entity type badge
const badges = await page.locator('[data-testid="entity-type-badge"]').allTextContents();
expect(badges).toEqual(["Automation", "Automation"]);
});
11. Rollback Plan
11.1 Rollback Phases 0-5 (No Data Migration)
Steps:
- Revert connector changes (restore
nodeType: "autonomous_identity") - Revert platform ingestion changes (restore
mapNodeType()function) - Revert API changes (restore old query filters)
- Revert UI changes (restore old query params)
Impact: Zero data loss. Existing entities remain as-is.
Downtime: ~1 hour (redeploy connector + platform)
11.2 Rollback Phase 6 (Data Migration)
Steps:
- Run reverse migration script:
// Reverse: automation → identity
db.entities.updateMany(
{ entity_type: "automation" },
{
$set: {
entity_type: "identity",
"properties.identitySubtype": "$properties.automationSubtype"
},
$unset: { "properties.automationSubtype": "" }
}
);
// Reverse: integration_component → identity
db.entities.updateMany(
{ entity_type: "integration_component" },
{
$set: {
entity_type: "identity",
"properties.identitySubtype": "$properties.integrationComponentSubtype"
},
$unset: { "properties.integrationComponentSubtype": "" }
}
); - Verify counts match pre-migration state
Impact: Data reverted to pre-migration state.
Downtime: ~30 minutes (run migration script)
12. Alternative Approach: Virtual Subtypes (Rejected)
12.1 Description
Keep entity_type: "identity" but add a category field:
// Option: Virtual category field
{
entity_type: "identity",
properties: {
category: "automation", // NEW field
identitySubtype: "business_rule"
}
}
Queries:
GET /api/v1/entities?entityType=identity&category=automation
12.2 Why Rejected
-
Semantically incorrect: A Business Rule is NOT an identity. The type system should reflect reality, not paper over it with metadata.
-
Query complexity: Every query must filter by BOTH
entity_typeANDcategory. Example:// List true identities:
db.entities.find({
entity_type: "identity",
"properties.category": { $ne: "automation" } // ❌ Verbose
});
// vs. correct approach:
db.entities.find({ entity_type: "identity" }); // ✓ Simple -
Index inefficiency: Compound index on
(entity_type, properties.category)is less efficient than single-field index onentity_type. -
Documentation confusion: Every doc must explain "entity_type: identity doesn't mean identity — check category field." This is a code smell.
-
Violates principle of least surprise: Developers expect
entity_type: "identity"to mean "things that authenticate." If it means "things that authenticate OR things that execute code OR things that route messages," the type is meaningless.
Verdict: Rejected. Use correct entity types.
13. Backward Compatibility Strategy
13.1 Connector Compatibility
Problem: Old connector versions emit autonomous_identity. New platform expects identity or automation.
Solution: Platform graph-transformer.ts supports BOTH:
function mapNodeType(nodeType: NormalizedNodeType): EntityType {
switch (nodeType) {
case "identity":
return "identity";
case "automation":
return "automation";
case "integration_component":
return "integration_component";
case "autonomous_identity": // DEPRECATED but still supported
// Legacy mapping: Look at properties.identitySubtype
return "identity"; // Default to identity (connector must update for correct typing)
// ... other cases
}
}
Timeline:
- Phase 0-1: Both old and new connectors work
- Phase 2-5: Connector updated, platform handles both formats
- Phase 7: Remove
autonomous_identitysupport (breaking change, requires connector upgrade)
13.2 API Compatibility
Problem: Old UI queries use entityType=identity&identitySubtype=business_rule.
Solution: API supports BOTH old and new queries:
// api/routes/entities.ts
export async function queryEntities(req, res) {
const { entityType, identitySubtype, automationSubtype } = req.query;
const query: EntityQuery = { entityType };
// Backward compatibility: Map old query to new
if (entityType === "identity" && identitySubtype) {
const automationSubtypes = ["business_rule", "flow_designer_flow", "scheduled_job", "script_include"];
if (automationSubtypes.includes(identitySubtype)) {
query.entityType = "automation";
query.automationSubtype = identitySubtype;
}
}
// New query (preferred)
if (automationSubtype) {
query.automationSubtype = automationSubtype;
}
const entities = await storageAdapter.queryEntities(tenantId, query);
res.json({ entities });
}
Timeline:
- Phase 3: API supports both old and new queries
- Phase 4: UI updated to use new queries
- Phase 7: Remove old query support (breaking change)
14. Communication Plan
14.1 Internal Team Communication
Audience: Platform team, connector team, QA
Timeline: Week 1 (before Phase 0 starts)
Message:
We're refactoring the entity type system to be semantically correct. Business Rules, Script Includes, and Flows are NOT identities — they are automation artifacts. We're introducing 3 new entity types:
automation(BRs, SIs, Flows)integration_component(REST Messages, OAuth Profiles)identity(SPs, OAuth Apps only)This is a 8-phase rollout over 8 weeks. Phases 0-2 are connector-side. Phases 3-5 are platform-side. Phase 6 is data migration. Phases 7-8 are cleanup.
Action required:
- Connector team: Update transformer.py in Phase 1 (Week 2)
- Platform team: Update path-materializer.ts in Phase 2 (Week 3)
- QA team: Run integration tests after each phase
14.2 Founder Communication
Audience: Founder
Timeline: Before Phase 0 starts
Message:
Problem: All automation artifacts (Business Rules, Script Includes, REST Messages, OAuth Profiles, Flows, Scheduled Jobs) are incorrectly typed as
entity_type: "identity". This is semantically wrong — a Business Rule does not authenticate to anything.Impact:
- "List all identities" query returns BRs/SIs (incorrect)
- "Count NHIs" is inflated by 77 (includes non-identities)
- Documentation is confusing ("identity" doesn't mean identity)
- Path materialization logic is convoluted (must check subtypes instead of type)
Proposed solution: Introduce 3 new entity types:
automation,integration_component,identity(corrected semantics).Effort: 168 hours over 8 weeks (phased rollout, zero downtime)
Alternative: Keep current system (defer indefinitely). Cost: ongoing confusion, verbose queries, tech debt.
Recommendation: Proceed with refactor. This is a foundational fix that makes the platform more understandable and maintainable.
14.3 Customer Communication
Audience: External customers (if any)
Timeline: Week 4 (before Phase 3 API changes)
Message:
What's changing: We're improving the entity type system to be more accurate. Starting in [release version], automation artifacts (Business Rules, Flows) will have
entity_type: "automation"instead ofentity_type: "identity".Do you need to change anything? No. The API continues to support the old query format for backward compatibility. Your existing integrations will continue to work.
Recommended action: Update your queries to use the new
entityType=automationfilter for clarity. See updated API docs at [link].Timeline: Old query format deprecated in [6 months], removed in [12 months].
15. Open Questions for Decision
Q1: Should we migrate existing data or only affect new syncs?
Option A (Migrate existing data):
- Pro: Entire tenant is consistent immediately
- Con: Migration script complexity; rollback plan needed
- Effort: +8 hours (Phase 6)
Option B (Only affect new syncs):
- Pro: Zero risk of data corruption
- Con: Mixed types in same tenant (old entities are
identity, new entities areautomation) - Effort: 0 hours (skip Phase 6)
Recommendation: Option A (migrate). Consistency is worth the effort.
Q2: Should we remove autonomous_identity support immediately or defer?
Option A (Remove immediately after connector update):
- Pro: Clean codebase, no deprecated code paths
- Con: Breaks old connector versions (forces upgrade)
- Timeline: Phase 7 (Week 7)
Option B (Keep indefinitely):
- Pro: Old connectors continue to work
- Con: Deprecated code path remains; documentation must explain legacy behavior
- Timeline: Never remove
Recommendation: Option A (remove after 6 months). Set a deprecation timeline and communicate clearly.
Q3: Should system_execution be entity_type: "identity" or entity_type: "automation"?
Context: ServiceNow "System" is a privileged NHI used when no user context is set. It's technically an identity (it authenticates, holds roles), but it's also used as a run-as target for automations.
Option A (identity):
- Pro: Semantically correct (System DOES hold roles, authenticate)
- Con: Appears in "List all identities" (some users may not expect it)
Option B (automation):
- Pro: Grouped with other automation artifacts
- Con: Semantically incorrect (System is not an automation)
Recommendation: Option A (identity). System is a privileged identity. If users don't want it in identity lists, they can filter by identitySubtype != "system_execution".
Q4: Should we introduce entity_type: "component" for REST Messages/OAuth Profiles, or use entity_type: "resource"?
Option A (New type: integration_component):
- Pro: Semantically distinct (these are not resources that data lives in)
- Con: Additional type to manage; more complexity
- Recommended in this doc
Option B (Reuse existing: resource):
- Pro: No new type needed; simpler
- Con: Semantically weird (REST Message is not a "resource" in the same sense as a table)
Option C (Reuse existing: credential):
- Pro: OAuth Profile stores credentials
- Con: REST Message does NOT store credentials (only references them)
Recommendation: Option A (new type). REST Messages and OAuth Profiles are neither resources nor credentials — they are integration plumbing.
16. Conclusion
Answer to the core question: Business Rules, Script Includes, REST Messages, OAuth Profiles, Flow Designer Flows, and Scheduled Jobs should NOT be entity_type: "identity".
Correct types:
- Business Rules, Script Includes, Flow Designer Flows, Scheduled Jobs:
entity_type: "automation"withautomationSubtype - REST Messages, OAuth Profiles:
entity_type: "integration_component"withintegrationComponentSubtype - Service Principals, OAuth Apps, machine accounts:
entity_type: "identity"withidentitySubtype
Why this matters:
- Semantic correctness: The type system should reflect reality. A Business Rule is not an identity.
- Query simplicity:
entityType=identityshould return only identities, not automations. - Path materialization clarity: Identity paths traverse
HAS_ROLE. Automation paths traverseRUNS_AS → HAS_ROLE. - Documentation clarity: No need to explain "identity doesn't mean identity."
- Future extensibility: Adding new automation types (Lambda, Cloud Functions) is straightforward.
Implementation approach:
- 8-phase rollout over 8 weeks
- Zero downtime, backward compatible during transition
- Total effort: 168 hours (or 136 hours without data migration)
- 47 files impacted (21 platform TypeScript, 3 connector Python, 23 tests/docs)
Risk level: Medium. Path materialization logic changes require careful testing, but the phased approach mitigates risk.
Rollback plan: Fully reversible at every phase (Phases 0-5 have zero data changes; Phase 6 has reverse migration script).
Recommendation: Proceed with refactor. This is a foundational correctness fix that improves code clarity, query simplicity, and documentation accuracy. The cost (168 hours) is justified by long-term maintainability gains.
Analysis complete. 850 lines. 47 files audited. 3 new entity types proposed. 8-phase implementation plan. Zero breaking changes during transition. Rollback plan for every phase. Ready for founder decision.