Skip to main content

Automation Persistence: Developer Implementation Analysis

Author: Developer (Implementation Specialist) Date: 2026-02-13 Context: Critical analysis of implementation costs for tracking execution chains as persistent entities

Executive Summary

This document analyzes four implementation options for persisting autonomous execution chains in the platform from a pure engineering effort and feasibility perspective. The analysis focuses on code changes, testing requirements, migration complexity, and risk.

Recommendation: Start with Option B (Lightweight Collection) as the initial implementation (1-2 weeks), with a clear path to upgrade to Option C (Temporal Tracking) post-MVP (additional 1 week).

Key Finding: Option A (computed view) appears simplest but creates a temporal query performance trap requiring N database queries per chain reconstruction at time T. Option D (virtual entity) causes architectural pollution without clear benefit.

Platform Context

Current Architecture (as of Phase 8)

Stack: TypeScript, Express, MongoDB, React + TanStack Query + ReactFlow Test Coverage: 205 unit + 84 integration + 19 UI tests Codebase Size: ~15,000 LOC backend, ~8,000 LOC frontend

Key Components:

  • Storage Layer: src/storage/mongo/adapter.ts (~750 lines) - handles all CRUD, versioning, temporal queries
  • Ingestion Pipeline: src/workers/handlers/sync-ingestion.ts (~190 lines) - orchestrates transform → diff → upsert → version → materialize
  • Graph Transformer: src/ingestion/graph-transformer.ts - converts NormalizedGraph → EntityDoc
  • Path Materializer: src/ingestion/path-materializer.ts - BFS traversal to compute execution_paths
  • Diff Engine: src/ingestion/diff-engine.ts - detects changes, emits events
  • API Routes: src/api/routes/entities.ts - RESTful entity endpoints with temporal queries
  • UI Components: ui/src/pages/EntitiesListPage.tsx, ui/src/components/AutomationFlowDiagram.tsx

Collections (10 total):

entities, entity_versions, events, findings, evidence_packs,
execution_evidence, baseline_metadata, baseline_entities,
connector_syncs, sync_cursors

Entity Types (6):

type EntityType = "identity" | "owner" | "role" | "permission" | "resource" | "credential"

Relationship Types (14):

OWNED_BY, HAS_ROLE, GRANTS, APPLIES_TO, AUTHENTICATES_TO, RUNS_AS,
TRIGGERS_ON, EXECUTES_ON, CREATED_BY, DELEGATES_TO, APPROVED_BY,
MEMBER_OF, BELONGS_TO, AUTHENTICATES_VIA

Option A: Computed View (No Schema Change)

Implementation

Add a new API endpoint that computes execution chains on-the-fly by traversing entity relationships.

// New file: src/api/routes/execution-chains.ts (~200 lines)

interface ComputedChain {
chain_id: string;
anchor_entity_id: string;
name: string;
chain_type: "scheduled" | "event_driven" | "on_demand";
entities: Array<{
entity_id: string;
role: "trigger" | "entry_point" | "executor" | "auth" | "destination";
entity_type: string;
display_name: string;
}>;
summary: {
trigger: string;
destination: string;
egress_category?: string;
blast_radius_domains: string[];
ownership_status?: string;
finding_count: number;
};
first_detected_at: Date;
last_seen_at: Date;
}

async function computeExecutionChains(
tenantId: string,
storageAdapter: StorageAdapter
): Promise<ComputedChain[]> {
// 1. Find all automation identity entities (identitySubtype = business_rule, flow, etc.)
const automations = await storageAdapter.queryEntities(tenantId, {
entityType: "identity",
identitySubtype: "business_rule,flow_designer_flow,scheduled_job,system_execution",
limit: 0 // All automations
});

const chains: ComputedChain[] = [];

for (const automation of automations) {
// 2. Build chain by traversing relationships
const triggerRel = automation.relationships.find(r => r.type === "TRIGGERS_ON");
const triggerEntity = triggerRel ? await storageAdapter.getEntity(tenantId, triggerRel.target_id) : null;

const executesOnRel = automation.relationships.find(r => r.type === "EXECUTES_ON");
const executesOnEntity = executesOnRel ? await storageAdapter.getEntity(tenantId, executesOnRel.target_id) : null;

const runsAsRel = automation.relationships.find(r => r.type === "RUNS_AS");
const runsAsEntity = runsAsRel ? await storageAdapter.getEntity(tenantId, runsAsRel.target_id) : null;

// 3. Extract blast radius from RUNS_AS identity's execution_paths
const executionPaths = runsAsEntity?.execution_paths ?? [];
const domains = [...new Set(executionPaths.map(p => p.business_domain))];

// 4. Count findings referencing this automation
const findings = await storageAdapter.queryFindings(tenantId, {
entityId: automation._id
});

chains.push({
chain_id: automation._id, // Use automation entity ID as chain ID
anchor_entity_id: automation._id,
name: (automation.properties.display_name as string) ?? automation.source_id,
chain_type: inferChainType(automation, triggerRel),
entities: [
...(triggerEntity ? [{ entity_id: triggerEntity._id, role: "trigger" as const, entity_type: triggerEntity.entity_type, display_name: triggerEntity.properties.display_name as string }] : []),
{ entity_id: automation._id, role: "entry_point" as const, entity_type: automation.entity_type, display_name: automation.properties.display_name as string },
...(executesOnEntity ? [{ entity_id: executesOnEntity._id, role: "destination" as const, entity_type: executesOnEntity.entity_type, display_name: executesOnEntity.properties.display_name as string }] : []),
...(runsAsEntity ? [{ entity_id: runsAsEntity._id, role: "auth" as const, entity_type: runsAsEntity.entity_type, display_name: runsAsEntity.properties.display_name as string }] : [])
],
summary: {
trigger: triggerEntity?.properties.display_name as string ?? "Unknown",
destination: executesOnEntity?.properties.display_name as string ?? "No egress",
egress_category: automation.properties.egress_category as string | undefined,
blast_radius_domains: domains.filter(Boolean),
ownership_status: automation.properties.ownership_status as string | undefined,
finding_count: findings.length
},
first_detected_at: automation.created_at,
last_seen_at: automation.last_synced_at
});
}

return chains;
}

Temporal Query Implementation

Problem: To get a chain's state at time T, must reconstruct EVERY entity in the chain at time T.

async function getChainAtTime(
tenantId: string,
chainId: string,
asOf: Date,
storageAdapter: StorageAdapter
): Promise<ComputedChain | null> {
// 1. Get automation entity version at asOf
const automationVersion = await storageAdapter.getEntityVersion(tenantId, chainId, asOf);
if (!automationVersion) return null;

// 2. Get each related entity version at asOf
const triggerRel = automationVersion.relationships.find(r => r.type === "TRIGGERS_ON");
const triggerVersion = triggerRel ? await storageAdapter.getEntityVersion(tenantId, triggerRel.target_id, asOf) : null;

const runsAsRel = automationVersion.relationships.find(r => r.type === "RUNS_AS");
const runsAsVersion = runsAsRel ? await storageAdapter.getEntityVersion(tenantId, runsAsRel.target_id, asOf) : null;

const executesOnRel = automationVersion.relationships.find(r => r.type === "EXECUTES_ON");
const executesOnVersion = executesOnRel ? await storageAdapter.getEntityVersion(tenantId, executesOnRel.target_id, asOf) : null;

// 3. Reconstruct blast radius at asOf (expensive!)
const executionPaths = runsAsVersion?.execution_paths ?? [];
// BUT: execution_paths points to resource IDs that may have changed!
// Need to fetch each resource's version at asOf to get accurate blast radius
// This could be 10-100+ additional queries per chain

// Total queries: 4 (entity versions) + N (resource versions) = 4-100+ per chain
}

Performance Analysis:

  • Current state queries: 5-10 DB calls per chain (acceptable)
  • Temporal queries: 4-100+ DB calls per chain (unacceptable at scale)
  • List 50 chains at time T: 200-5000 DB queries (catastrophic)

Code Changes

New Files (1):

  • src/api/routes/execution-chains.ts (~200 lines)

Modified Files (3):

  • src/api/server.ts - register new route (~2 lines)
  • src/storage/storage-adapter.ts - interface for chain queries (~20 lines)
  • src/storage/mongo/adapter.ts - implement chain queries (~100 lines)

UI Files (2 new):

  • ui/src/pages/ExecutionChainsPage.tsx (~250 lines) - list view with DataTable
  • ui/src/pages/ExecutionChainDetailPage.tsx (~200 lines) - detail view with temporal timeline

Testing Requirements

Unit Tests (3 files, ~150 lines):

  • test/api/execution-chains.test.ts - endpoint tests
  • test/storage/chain-queries.test.ts - chain assembly logic
  • test/ingestion/chain-inference.test.ts - chain type detection

Integration Tests (2 files, ~100 lines):

  • test/integration/api/execution-chains.test.ts - full stack tests
  • test/integration/temporal-chain-queries.test.ts - temporal query performance

UI Tests (2 files, ~80 lines):

  • ui/src/pages/ExecutionChainsPage.test.tsx - list page rendering
  • ui/src/pages/ExecutionChainDetailPage.test.tsx - detail page interactions

Effort Estimate

TaskHours
API route + chain assembly logic12
Storage adapter methods6
Temporal query implementation8
UI pages (list + detail)16
Unit tests8
Integration tests6
UI tests4
Documentation2
Total62 hours (~1.5 weeks)

Pros

  • No schema migration
  • No data backfill
  • Works with existing temporal infrastructure
  • Can ship quickly

Cons

  • Temporal queries scale poorly (N queries per chain)
  • Chain assembly logic scattered across API layer
  • No stable chain ID (tied to automation entity ID)
  • No chain-specific metadata (e.g., "chain last validated on 2026-02-10")
  • Hard to add chain-level features later (e.g., chain tagging, chain-level findings)

Migration Path

  • A → B: Easy - add collection, backfill chains by running compute logic once, switch API to read from collection
  • A → D: Moderate - add entity type, backfill as entities, update UI filters

Option B: execution_chains Collection (Lightweight)

Implementation

Add a dedicated collection to store pre-computed chains. Update ingestion pipeline to assemble and upsert chains after path materialization.

// New file: src/domain/chains/types.ts (~80 lines)

export interface ChainEntityRef {
entity_id: string;
role: "trigger" | "entry_point" | "executor" | "auth" | "destination";
entity_type: string;
display_name: string;
}

export interface ChainSummary {
trigger: string;
destination: string;
egress_category?: string;
blast_radius_domains: string[];
ownership_status?: string;
finding_count: number;
}

export interface ExecutionChainDoc {
_id: string; // Stable chain ID (hash of anchor_entity_id + chain structure fingerprint)
tenant_id: string;
name: string;
anchor_entity_id: string; // The automation identity entity
chain_type: "scheduled" | "event_driven" | "on_demand" | "unknown";
entity_refs: ChainEntityRef[];
summary: ChainSummary;
first_detected_at: Date;
last_seen_at: Date;
sync_version: number;
created_at: Date;
updated_at: Date;
}

Storage Adapter Methods:

// Add to src/storage/storage-adapter.ts (~40 lines)

export interface ChainQuery {
chainType?: string;
anchorEntityId?: string;
egressCategory?: string;
ownershipStatus?: string;
limit?: number;
offset?: number;
sort?: string;
afterId?: string;
}

export interface StorageAdapter {
// ... existing methods

// Chains
upsertChain(chain: ExecutionChainDoc): Promise<{ upserted: boolean }>;
getChain(tenantId: string, chainId: string): Promise<ExecutionChainDoc | null>;
queryChains(tenantId: string, query: ChainQuery): Promise<ExecutionChainDoc[]>;
countChains(tenantId: string, query?: ChainQuery): Promise<number>;
}

Ingestion Pipeline Changes:

// Modify: src/workers/handlers/sync-ingestion.ts (~30 lines added)

export function createSyncIngestionHandler(...) {
return async (job: WorkerJob): Promise<void> => {
// ... existing steps 1-8 (transform, diff, upsert, version, materialize)

// 9. Assemble and upsert execution chains
const chains = await assembleExecutionChains(
affectedIdentityIds,
tenantId,
syncVersion,
storageAdapter
);

let chainsUpserted = 0;
for (const chain of chains) {
const result = await storageAdapter.upsertChain(chain);
if (result.upserted) chainsUpserted++;
}

// 10. Update sync metrics
await storageAdapter.updateConnectorSync(tenantId, syncId, {
status: "completed",
metrics: {
// ... existing metrics
chains_upserted: chainsUpserted
}
});
};
}

Chain Assembly Logic:

// New file: src/ingestion/chain-assembler.ts (~150 lines)

export async function assembleExecutionChains(
affectedIdentityIds: string[],
tenantId: string,
syncVersion: number,
storageAdapter: StorageAdapter
): Promise<ExecutionChainDoc[]> {
const chains: ExecutionChainDoc[] = [];
const now = new Date();

for (const identityId of affectedIdentityIds) {
const identity = await storageAdapter.getEntity(tenantId, identityId);
if (!identity || identity.entity_type !== "identity") continue;

// Only process automation subtypes
const subtype = identity.properties.identitySubtype as string | undefined;
if (!isAutomationSubtype(subtype)) continue;

// Fetch related entities
const triggerRel = identity.relationships.find(r => r.type === "TRIGGERS_ON");
const triggerEntity = triggerRel ? await storageAdapter.getEntity(tenantId, triggerRel.target_id) : null;

const executesOnRel = identity.relationships.find(r => r.type === "EXECUTES_ON");
const executesOnEntity = executesOnRel ? await storageAdapter.getEntity(tenantId, executesOnRel.target_id) : null;

const runsAsRel = identity.relationships.find(r => r.type === "RUNS_AS");
const runsAsEntity = runsAsRel ? await storageAdapter.getEntity(tenantId, runsAsRel.target_id) : null;

// Build entity refs
const entityRefs: ChainEntityRef[] = [];
if (triggerEntity) {
entityRefs.push({
entity_id: triggerEntity._id,
role: "trigger",
entity_type: triggerEntity.entity_type,
display_name: (triggerEntity.properties.display_name as string) ?? triggerEntity.source_id
});
}
entityRefs.push({
entity_id: identity._id,
role: "entry_point",
entity_type: identity.entity_type,
display_name: (identity.properties.display_name as string) ?? identity.source_id
});
if (runsAsEntity) {
entityRefs.push({
entity_id: runsAsEntity._id,
role: "auth",
entity_type: runsAsEntity.entity_type,
display_name: (runsAsEntity.properties.display_name as string) ?? runsAsEntity.source_id
});
}
if (executesOnEntity) {
entityRefs.push({
entity_id: executesOnEntity._id,
role: "destination",
entity_type: executesOnEntity.entity_type,
display_name: (executesOnEntity.properties.display_name as string) ?? executesOnEntity.source_id
});
}

// Extract blast radius from RUNS_AS identity
const executionPaths = runsAsEntity?.execution_paths ?? [];
const domains = [...new Set(executionPaths.map(p => p.business_domain).filter(Boolean))];

// Build chain
const chainId = buildChainId(tenantId, identity._id, entityRefs);
const existingChain = await storageAdapter.getChain(tenantId, chainId);

chains.push({
_id: chainId,
tenant_id: tenantId,
name: (identity.properties.display_name as string) ?? identity.source_id,
anchor_entity_id: identity._id,
chain_type: inferChainType(identity, triggerRel),
entity_refs: entityRefs,
summary: {
trigger: triggerEntity ? (triggerEntity.properties.display_name as string) : "Unknown",
destination: executesOnEntity ? (executesOnEntity.properties.display_name as string) : "No egress",
egress_category: identity.properties.egress_category as string | undefined,
blast_radius_domains: domains,
ownership_status: identity.properties.ownership_status as string | undefined,
finding_count: 0 // Updated by finding evaluator
},
first_detected_at: existingChain?.first_detected_at ?? now,
last_seen_at: now,
sync_version: syncVersion,
created_at: existingChain?.created_at ?? now,
updated_at: now
});
}

return chains;
}

function buildChainId(tenantId: string, anchorEntityId: string, entityRefs: ChainEntityRef[]): string {
// Stable ID based on anchor + entity ref fingerprint
const fingerprint = entityRefs.map(r => `${r.role}:${r.entity_id}`).sort().join("|");
const hash = createHash("sha256");
hash.update(tenantId);
hash.update(":");
hash.update(anchorEntityId);
hash.update(":");
hash.update(fingerprint);
return `chain-${hash.digest("hex").slice(0, 20)}`;
}

MongoStorageAdapter Implementation:

// Add to src/storage/mongo/adapter.ts (~120 lines)

async upsertChain(chain: ExecutionChainDoc): Promise<{ upserted: boolean }> {
const { _id, created_at, ...fields } = chain;
const result = await this.c.chains.updateOne(
{ _id, tenant_id: chain.tenant_id },
{ $set: fields, $setOnInsert: { _id, created_at } },
{ upsert: true }
);
return { upserted: result.upsertedCount > 0 };
}

async getChain(tenantId: string, chainId: string): Promise<ExecutionChainDoc | null> {
return this.c.chains.findOne({ _id: chainId, tenant_id: tenantId });
}

async queryChains(tenantId: string, query: ChainQuery): Promise<ExecutionChainDoc[]> {
const filter: Record<string, unknown> = { tenant_id: tenantId };

if (query.chainType) filter.chain_type = query.chainType;
if (query.anchorEntityId) filter.anchor_entity_id = query.anchorEntityId;
if (query.egressCategory) filter["summary.egress_category"] = query.egressCategory;
if (query.ownershipStatus) filter["summary.ownership_status"] = query.ownershipStatus;
if (query.afterId) filter._id = { $gt: query.afterId };

const limit = query.limit ?? 50;
const skip = query.offset ?? 0;
const sortSpec = parseSortField(query.sort, CHAIN_SORT_FIELDS, { last_seen_at: -1 });

const cursor = this.c.chains.find(filter).sort(sortSpec);
if (!query.afterId) cursor.skip(skip);
if (limit > 0) cursor.limit(limit);
return cursor.toArray();
}

async countChains(tenantId: string, query?: ChainQuery): Promise<number> {
const filter: Record<string, unknown> = { tenant_id: tenantId };
if (query?.chainType) filter.chain_type = query.chainType;
if (query?.anchorEntityId) filter.anchor_entity_id = query.anchorEntityId;
if (query?.egressCategory) filter["summary.egress_category"] = query.egressCategory;
if (query?.ownershipStatus) filter["summary.ownership_status"] = query.ownershipStatus;
return this.c.chains.countDocuments(filter);
}

API Routes:

// New file: src/api/routes/execution-chains.ts (~120 lines)

export function createExecutionChainRoutes(storageAdapter: StorageAdapter): Router {
const router = Router();

router.get("/api/v1/execution-chains", async (req, res) => {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: { code: "TENANT_CONTEXT_MISSING", message: "Tenant context is required", status: 400 } });
return;
}

const limit = parseInt(req.query.limit as string, 10) || 50;
const cursorParam = req.query.cursor as string | undefined;
const afterId = cursorParam ? decodeCursor(cursorParam) : null;

const queryFilters = {
...(req.query.chain_type ? { chainType: req.query.chain_type as string } : {}),
...(req.query.egress_category ? { egressCategory: req.query.egress_category as string } : {}),
...(req.query.ownership_status ? { ownershipStatus: req.query.ownership_status as string } : {}),
...(req.query.sort ? { sort: req.query.sort as string } : {})
};

const chains = await storageAdapter.queryChains(tenantId, {
...queryFilters,
...(afterId ? { afterId } : {}),
limit: limit + 1
});

const totalCount = await storageAdapter.countChains(tenantId, queryFilters);
const { data, cursor } = buildCursorResponse(chains, limit);

res.status(200).json({
data,
cursor,
meta: { total_count: totalCount }
});
});

router.get("/api/v1/execution-chains/:id", async (req, res) => {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: { code: "TENANT_CONTEXT_MISSING", message: "Tenant context is required", status: 400 } });
return;
}

const chain = await storageAdapter.getChain(tenantId, req.params.id);
if (!chain) {
res.status(404).json({ error: { code: "NOT_FOUND", message: "Chain not found", status: 404 } });
return;
}

res.status(200).json({ data: chain });
});

return router;
}

Code Changes

New Files (5):

  • src/domain/chains/types.ts (~80 lines)
  • src/ingestion/chain-assembler.ts (~150 lines)
  • src/api/routes/execution-chains.ts (~120 lines)
  • test/ingestion/chain-assembler.test.ts (~180 lines)
  • test/api/execution-chains.test.ts (~120 lines)

Modified Files (5):

  • src/storage/mongo/collections.ts - add chains collection (~5 lines)
  • src/storage/storage-adapter.ts - add chain interface (~40 lines)
  • src/storage/mongo/adapter.ts - implement chain methods (~120 lines)
  • src/workers/handlers/sync-ingestion.ts - call chain assembler (~30 lines)
  • src/api/server.ts - register chain routes (~2 lines)

UI Files (2 new):

  • ui/src/pages/ExecutionChainsPage.tsx (~280 lines) - list view with DataTable + filters
  • ui/src/pages/ExecutionChainDetailPage.tsx (~250 lines) - detail view with entity refs + summary

Total New Lines: ~1,180 backend + ~530 UI = ~1,710 lines

Testing Requirements

Unit Tests (5 files, ~380 lines):

  • test/ingestion/chain-assembler.test.ts (~180 lines) - chain assembly logic, ID stability, entity ref construction
  • test/api/execution-chains.test.ts (~120 lines) - API endpoint tests
  • test/storage/chain-queries.test.ts (~80 lines) - storage adapter tests

Integration Tests (2 files, ~160 lines):

  • test/integration/ingestion/chain-assembly.test.ts (~100 lines) - end-to-end chain assembly during sync
  • test/integration/api/execution-chains.test.ts (~60 lines) - full stack chain API tests

UI Tests (2 files, ~100 lines):

  • ui/src/pages/ExecutionChainsPage.test.tsx (~50 lines) - list page rendering + filters
  • ui/src/pages/ExecutionChainDetailPage.test.tsx (~50 lines) - detail page + entity ref display

Effort Estimate

TaskHours
Domain types + chain assembler12
Storage adapter methods8
Ingestion pipeline integration6
API routes8
UI pages (list + detail)20
Unit tests12
Integration tests8
UI tests6
Schema migration script4
Data backfill script6
Documentation4
Total94 hours (~2.3 weeks)

Pros

  • Fast queries (single collection read, no joins)
  • Stable chain IDs independent of entity IDs
  • Clean separation of concerns (chains vs entities)
  • Can add chain-specific metadata easily
  • Clear upgrade path to Option C

Cons

  • Requires schema migration + data backfill
  • Chains become stale if entities change outside sync pipeline
  • No built-in temporal tracking (see Option C)

Migration Path

  • B → C: Easy - add execution_chain_versions collection, start versioning on changes
  • B → A: Trivial - drop collection, switch API to compute on-the-fly
  • B → D: Hard - migrate chains to entities, update all queries

Option C: Rich Temporal Chains (Option B + Versioning)

Implementation

Extend Option B with a execution_chain_versions collection to track how chains evolve over time.

// Add to src/domain/chains/types.ts (~40 lines)

export interface BlastRadiusSnapshot {
resource_id: string;
resource_name: string;
business_domain: string;
sensitivity: string;
actions: string[];
}

export interface ExecutionChainVersionDoc {
_id?: string;
chain_id: string;
tenant_id: string;
valid_at: Date;
expired_at: Date | null;
sync_version: number;
entity_refs: ChainEntityRef[];
summary: ChainSummary;
blast_radius_snapshot: BlastRadiusSnapshot[];
}

Storage Adapter Changes:

// Add to src/storage/storage-adapter.ts (~30 lines)

export interface StorageAdapter {
// ... existing + Option B methods

// Chain Versioning
insertChainVersion(version: ExecutionChainVersionDoc): Promise<void>;
expireChainVersion(tenantId: string, chainId: string, expiredAt: Date): Promise<void>;
getChainVersion(tenantId: string, chainId: string, asOf: Date): Promise<ExecutionChainVersionDoc | null>;
getChainVersionHistory(tenantId: string, chainId: string, limit?: number): Promise<ExecutionChainVersionDoc[]>;
}

Ingestion Pipeline Changes:

// Modify: src/workers/handlers/sync-ingestion.ts (~40 lines added)

// After chain assembly (step 9):
for (const chain of chains) {
const existing = await storageAdapter.getChain(tenantId, chain._id);

// Detect changes
let changed = false;
if (existing) {
const entityRefsDiff = JSON.stringify(existing.entity_refs) !== JSON.stringify(chain.entity_refs);
const summaryDiff = JSON.stringify(existing.summary) !== JSON.stringify(chain.summary);
changed = entityRefsDiff || summaryDiff;
} else {
changed = true; // New chain
}

// Upsert chain
await storageAdapter.upsertChain(chain);

// Create version if changed
if (changed) {
// Expire previous version
await storageAdapter.expireChainVersion(tenantId, chain._id, now);

// Snapshot blast radius
const runsAsEntity = chain.entity_refs.find(r => r.role === "auth");
const blastRadiusSnapshot: BlastRadiusSnapshot[] = [];
if (runsAsEntity) {
const authEntity = await storageAdapter.getEntity(tenantId, runsAsEntity.entity_id);
if (authEntity?.execution_paths) {
for (const path of authEntity.execution_paths) {
blastRadiusSnapshot.push({
resource_id: path.resource_id,
resource_name: path.resource_name,
business_domain: path.business_domain,
sensitivity: path.sensitivity,
actions: path.actions
});
}
}
}

// Insert new version
await storageAdapter.insertChainVersion({
chain_id: chain._id,
tenant_id: tenantId,
valid_at: now,
expired_at: null,
sync_version: syncVersion,
entity_refs: chain.entity_refs,
summary: chain.summary,
blast_radius_snapshot: blastRadiusSnapshot
});
}
}

API Routes:

// Add to src/api/routes/execution-chains.ts (~80 lines)

router.get("/api/v1/execution-chains/:id/at/:timestamp", async (req, res) => {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: { code: "TENANT_CONTEXT_MISSING", message: "Tenant context is required", status: 400 } });
return;
}

const asOf = new Date(req.params.timestamp);
if (isNaN(asOf.getTime())) {
res.status(400).json({ error: { code: "INVALID_TIMESTAMP", message: "Invalid timestamp format", status: 400 } });
return;
}

const version = await storageAdapter.getChainVersion(tenantId, req.params.id, asOf);
if (!version) {
res.status(404).json({ error: { code: "NOT_FOUND", message: "No chain version exists at that point in time", status: 404 } });
return;
}

res.status(200).json({ data: { ...version, reconstructed_at: asOf.toISOString() } });
});

router.get("/api/v1/execution-chains/:id/diff", async (req, res) => {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: { code: "TENANT_CONTEXT_MISSING", message: "Tenant context is required", status: 400 } });
return;
}

const from = req.query.from as string | undefined;
const to = req.query.to as string | undefined;
if (!from || !to) {
res.status(400).json({ error: { code: "MISSING_PARAMS", message: "from and to query parameters are required", status: 400 } });
return;
}

const fromDate = new Date(from);
const toDate = new Date(to);

const fromVersion = await storageAdapter.getChainVersion(tenantId, req.params.id, fromDate);
const toVersion = await storageAdapter.getChainVersion(tenantId, req.params.id, toDate);

if (!fromVersion || !toVersion) {
res.status(404).json({ error: { code: "NOT_FOUND", message: "Chain version not found for specified time range", status: 404 } });
return;
}

// Compute diff
const entityRefsDiff = diffEntityRefs(fromVersion.entity_refs, toVersion.entity_refs);
const blastRadiusDiff = diffBlastRadius(fromVersion.blast_radius_snapshot, toVersion.blast_radius_snapshot);

res.status(200).json({
data: {
from_version: fromVersion,
to_version: toVersion,
entity_refs_changes: entityRefsDiff,
blast_radius_changes: blastRadiusDiff
}
});
});

router.get("/api/v1/execution-chains/:id/history", async (req, res) => {
const tenantId = req.tenantId;
if (!tenantId) {
res.status(400).json({ error: { code: "TENANT_CONTEXT_MISSING", message: "Tenant context is required", status: 400 } });
return;
}

const limit = parseInt(req.query.limit as string, 10) || 20;
const versions = await storageAdapter.getChainVersionHistory(tenantId, req.params.id, limit);

res.status(200).json({ data: versions });
});

Code Changes (Incremental over Option B)

New Files (2):

  • test/integration/chain-versioning.test.ts (~100 lines)
  • test/api/chain-temporal-queries.test.ts (~80 lines)

Modified Files (4):

  • src/domain/chains/types.ts - add version doc type (~40 lines)
  • src/storage/storage-adapter.ts - add version methods (~30 lines)
  • src/storage/mongo/adapter.ts - implement version methods (~100 lines)
  • src/workers/handlers/sync-ingestion.ts - add versioning logic (~40 lines)
  • src/api/routes/execution-chains.ts - add temporal endpoints (~80 lines)

UI Changes (1 modified):

  • ui/src/pages/ExecutionChainDetailPage.tsx - add temporal comparison tab (~80 lines)

Incremental New Lines: ~550 backend + ~80 UI = ~630 lines (total with B: ~2,340 lines)

Effort Estimate (Incremental over Option B)

TaskHours
Version types + storage methods8
Ingestion versioning logic6
Temporal API endpoints6
Diff computation logic6
UI temporal comparison tab8
Unit tests6
Integration tests6
Documentation2
Incremental Total48 hours (~1.2 weeks)
Total (B + C)142 hours (~3.5 weeks)

Pros

  • Full temporal tracking (chain state at any time T)
  • Blast radius snapshots avoid expensive reconstruction
  • Supports drift detection (chain changed but not reviewed)
  • Enables forensic analysis ("what did this chain access 30 days ago?")
  • Single-query temporal lookups (no N+1 problem)

Cons

  • Requires two collections (chains + chain_versions)
  • More complex ingestion logic
  • Larger storage footprint (versions multiply data)

Migration Path

  • C → B: Trivial - drop chain_versions collection, keep chains
  • C → A: Easy - drop both collections, revert to computed view

Option D: Virtual Entity (Add entity_type: "execution_chain")

Implementation

Extend the existing EntityType enum to include "execution_chain", and store chains as first-class entities in the entities collection.

// Modify: src/domain/entities/types.ts

export const ENTITY_TYPES = [
"identity",
"owner",
"role",
"permission",
"resource",
"credential",
"execution_chain" // NEW
] as const;

export type EntityType = (typeof ENTITY_TYPES)[number];

EntityDoc Structure for Chains:

// Chains stored as EntityDoc with:
{
_id: "chain-abc123",
tenant_id: "default",
entity_type: "execution_chain",
source_system: "sv0-platform", // Synthetic source
source_id: "chain-automation-id-123", // Derived from anchor automation
properties: {
display_name: "Update ServiceNow Incident via API",
status: "active",
chain_type: "scheduled",
anchor_entity_id: "entity-automation-123",
trigger: "Record Update on incident table",
destination: "REST: api.example.com",
egress_category: "external_saas",
blast_radius_domains: ["IT Operations", "Security"],
ownership_status: "unknown",
finding_count: 2
},
relationships: [
{ type: "CONTAINS", target_id: "entity-trigger-456", properties: { role: "trigger" } },
{ type: "CONTAINS", target_id: "entity-automation-123", properties: { role: "entry_point" } },
{ type: "CONTAINS", target_id: "entity-sp-789", properties: { role: "auth" } },
{ type: "CONTAINS", target_id: "entity-rest-msg-999", properties: { role: "destination" } }
],
sync_version: 1234567890,
last_synced_at: Date,
created_at: Date,
updated_at: Date
}

Ingestion Pipeline Changes:

// Modify: src/workers/handlers/sync-ingestion.ts (~60 lines added)

// After entity upsert (step 4):
const chainEntities = await assembleChainEntities(
affectedIdentityIds,
tenantId,
syncVersion,
storageAdapter
);

// Upsert chain entities
await storageAdapter.upsertEntities(chainEntities);

// Compute diff for chain entities (detect changes)
const chainDiff = await computeDiff(
chainEntities,
tenantId,
syncId,
connectorId,
storageAdapter
);

// Insert chain events
await storageAdapter.insertEvents(chainDiff.events);

// Create versions for changed chain entities
for (const chainId of chainDiff.changedEntityIds) {
const chain = chainEntities.find(e => e._id === chainId);
if (!chain) continue;

await storageAdapter.expireEntityVersion(tenantId, chainId, now);
await storageAdapter.insertEntityVersion({
entity_id: chainId,
tenant_id: tenantId,
valid_at: now,
expired_at: null,
sync_version: syncVersion,
entity_type: "execution_chain",
properties: chain.properties,
relationships: chain.relationships
});
}

Chain Assembly as Entities:

// New file: src/ingestion/chain-entity-assembler.ts (~180 lines)

export async function assembleChainEntities(
affectedIdentityIds: string[],
tenantId: string,
syncVersion: number,
storageAdapter: StorageAdapter
): Promise<EntityDoc[]> {
const chainEntities: EntityDoc[] = [];
const now = new Date();

for (const identityId of affectedIdentityIds) {
const identity = await storageAdapter.getEntity(tenantId, identityId);
if (!identity || identity.entity_type !== "identity") continue;

const subtype = identity.properties.identitySubtype as string | undefined;
if (!isAutomationSubtype(subtype)) continue;

// Fetch related entities (same as Option B)
const triggerRel = identity.relationships.find(r => r.type === "TRIGGERS_ON");
const triggerEntity = triggerRel ? await storageAdapter.getEntity(tenantId, triggerRel.target_id) : null;

const executesOnRel = identity.relationships.find(r => r.type === "EXECUTES_ON");
const executesOnEntity = executesOnRel ? await storageAdapter.getEntity(tenantId, executesOnRel.target_id) : null;

const runsAsRel = identity.relationships.find(r => r.type === "RUNS_AS");
const runsAsEntity = runsAsRel ? await storageAdapter.getEntity(tenantId, runsAsRel.target_id) : null;

// Build CONTAINS relationships
const relationships: EntityRelationship[] = [];
if (triggerEntity) {
relationships.push({ type: "CONTAINS", target_id: triggerEntity._id, properties: { role: "trigger" } });
}
relationships.push({ type: "CONTAINS", target_id: identity._id, properties: { role: "entry_point" } });
if (runsAsEntity) {
relationships.push({ type: "CONTAINS", target_id: runsAsEntity._id, properties: { role: "auth" } });
}
if (executesOnEntity) {
relationships.push({ type: "CONTAINS", target_id: executesOnEntity._id, properties: { role: "destination" } });
}

// Extract blast radius
const executionPaths = runsAsEntity?.execution_paths ?? [];
const domains = [...new Set(executionPaths.map(p => p.business_domain).filter(Boolean))];

// Build chain entity
const chainId = buildStableEntityId(tenantId, "sv0-platform", `chain-${identity._id}`);

chainEntities.push({
_id: chainId,
tenant_id: tenantId,
entity_type: "execution_chain",
source_system: "sv0-platform",
source_id: `chain-${identity._id}`,
properties: {
display_name: (identity.properties.display_name as string) ?? identity.source_id,
status: identity.properties.status,
chain_type: inferChainType(identity, triggerRel),
anchor_entity_id: identity._id,
trigger: triggerEntity ? (triggerEntity.properties.display_name as string) : "Unknown",
destination: executesOnEntity ? (executesOnEntity.properties.display_name as string) : "No egress",
egress_category: identity.properties.egress_category,
blast_radius_domains: domains,
ownership_status: identity.properties.ownership_status,
finding_count: 0
},
relationships,
sync_version: syncVersion,
last_synced_at: now,
created_at: now,
updated_at: now
});
}

return chainEntities;
}

What Breaks?

1. Entity Filters in UI:

// ui/src/pages/EntitiesListPage.tsx
// EntityType filter dropdown now includes "execution_chain"
// User can filter to chains only — works as expected
// BUT: chains shown in same table as identities/roles/resources
// Need UI logic to hide chains from entity list by default

2. Graph Layout:

// ui/src/components/graph/layout.ts
// 7-layer execution ranking assumes entity_type in [identity, role, permission, resource, credential, owner]
// execution_chain entities break layer assignment
// Need to add special case: chains rendered as grouped nodes?

3. Path Materializer:

// src/ingestion/path-materializer.ts
// computePathsForIdentity() assumes entity_type === "identity"
// execution_chain entities would fail this check
// Need to explicitly filter out chains: if (entity.entity_type === "execution_chain") continue;

4. Finding Evaluator:

// src/domain/findings/evaluator.ts (not yet implemented, but planned)
// Likely assumes findings target entity_type in [identity, role, permission, resource]
// Need to add execution_chain as valid target type

5. Subgraph Queries:

// src/storage/mongo/adapter.ts - getSubgraph()
// Uses relationships to traverse graph
// CONTAINS relationships from chains → entities work fine
// BUT: reverse lookup (entities → chains containing them) requires explicit handling

6. Entity Detail Page:

// ui/src/pages/EntityDetailPage.tsx
// Shows 5 tabs: Overview, Relationships, Timeline, Evidence, Findings
// If user navigates to a chain entity, what does each tab show?
// - Overview: chain properties (works)
// - Relationships: CONTAINS edges (works)
// - Timeline: events on chain entity (works)
// - Evidence: no execution_evidence for chains (breaks? or just empty)
// - Findings: findings targeting chain entity (works if evaluator updated)

Code Changes

Modified Files (8):

  • src/domain/entities/types.ts - add execution_chain entity type (~2 lines)
  • src/ingestion/chain-entity-assembler.ts - NEW (~180 lines)
  • src/workers/handlers/sync-ingestion.ts - integrate chain assembly (~60 lines)
  • src/ingestion/path-materializer.ts - filter out chain entities (~3 lines)
  • ui/src/pages/EntitiesListPage.tsx - hide chains by default in entity list (~10 lines)
  • ui/src/components/graph/layout.ts - handle chain entity type in layout (~15 lines)
  • ui/src/pages/EntityDetailPage.tsx - special rendering for chain entities (~30 lines)
  • test/ingestion/chain-entity-assembler.test.ts - NEW (~150 lines)

Total New Lines: ~450 backend + ~55 UI = ~505 lines

Effort Estimate

TaskHours
Entity type modification2
Chain entity assembler12
Ingestion integration8
Path materializer fix2
UI entity list filter4
Graph layout fix6
Entity detail page adaptations6
Unit tests10
Integration tests8
Documentation4
Total62 hours (~1.5 weeks)

Pros

  • Reuses existing entity infrastructure (versioning, events, temporal queries)
  • No new collections
  • Chains appear in entity timeline automatically
  • Chains get audit trail for free

Cons

  • Architectural pollution: execution_chain is NOT a source-system entity
  • Breaks assumptions in path materializer, graph layout, UI filters
  • CONTAINS relationship is novel (no other entity uses it)
  • Chains mixed with real entities in queries (requires filtering everywhere)
  • Hard to distinguish "chain changed" from "entity changed" in event stream
  • Difficult to add chain-specific features (e.g., chain validation status, chain tagging)

Migration Path

  • D → B: Moderate - query all execution_chain entities, transform to ExecutionChainDoc, drop chain entities
  • D → A: Easy - drop all execution_chain entities, switch to computed view

Implementation Comparison Matrix

MetricOption A (Computed)Option B (Lightweight)Option C (Temporal)Option D (Virtual Entity)
New Files35+2 (7 total)2
Modified Files35+4 (9 total)8
New LOC (backend)~320~1,180+550 (1,730 total)~450
New LOC (UI)~450~530+80 (610 total)~55
Total New LOC~770~1,710~2,340~505
New Tests (files)79+2 (11 total)9
New Test LOC~330~640+180 (820 total)~510
New Collections0120
Schema MigrationNoneRequiredRequiredNone
Data BackfillNoneRequiredRequiredRequired
Effort (hours)62 (~1.5 weeks)94 (~2.3 weeks)142 (~3.5 weeks)62 (~1.5 weeks)
Ingestion ChangesNoneModerate (1 step added)Moderate (versioning added)Significant (entity assembly)
API ChangesNew routesNew routesNew routes + temporalExisting routes (filter changes)
UI ChangesNew pagesNew pages+1 tabPage adaptations
Neo4j PortabilityHigh (no schema)Moderate (map collection to labels)Low (versioning complex)High (chains as nodes)
Temporal Query PerfPoor (N queries/chain)N/AExcellent (1 query)Good (existing infra)
Current State PerfPoor (5-10 queries/chain)Excellent (1 query)Excellent (1 query)Good (1 query + filter)
Chain MetadataImpossibleEasyEasyHard (pollutes entity properties)
Architectural PurityCleanCleanCleanPolluted

Migration Paths Between Options

A → B (Computed → Lightweight Collection)

Effort: 20 hours Steps:

  1. Implement Option B (collection + ingestion)
  2. Run backfill script (one-time compute of all chains)
  3. Switch API routes to read from collection
  4. Deploy

Risk: Low (API contract unchanged, just reads from different source)

B → C (Lightweight → Temporal)

Effort: 48 hours Steps:

  1. Add execution_chain_versions collection
  2. Update ingestion to create versions on change
  3. Add temporal API endpoints
  4. Backfill historical versions from entity_versions (if needed)

Risk: Low (incremental addition, no breaking changes)

A → D (Computed → Virtual Entity)

Effort: 40 hours Steps:

  1. Implement Option D (entity type + assembler)
  2. Run backfill script (create chain entities)
  3. Update UI filters to hide chains by default
  4. Fix graph layout + path materializer

Risk: Moderate (entity type assumptions must be audited)

D → B (Virtual Entity → Lightweight Collection)

Effort: 60 hours Steps:

  1. Query all execution_chain entities
  2. Transform to ExecutionChainDoc format
  3. Insert into new execution_chains collection
  4. Delete chain entities from entities collection
  5. Update ingestion to use chain assembler instead of entity assembler
  6. Update API routes to read from chains collection

Risk: High (major refactor, potential data loss if migration fails)

C → B (Temporal → Lightweight)

Effort: 4 hours Steps:

  1. Drop execution_chain_versions collection
  2. Remove versioning logic from ingestion
  3. Remove temporal API endpoints

Risk: Negligible (data loss only on versions, chains preserved)


Testing Strategy

Option A (Computed View)

Unit Tests (3 files, ~150 lines):

  1. test/api/execution-chains.test.ts - chain assembly logic, entity traversal
  2. test/api/chain-temporal-queries.test.ts - temporal reconstruction logic
  3. test/storage/chain-queries.test.ts - query filter logic

Integration Tests (2 files, ~100 lines):

  1. test/integration/api/execution-chains.test.ts - full stack chain API (seed DB, call API, verify results)
  2. test/integration/temporal-chain-performance.test.ts - benchmark temporal query performance (N queries/chain)

UI Tests (2 files, ~80 lines):

  1. ui/src/pages/ExecutionChainsPage.test.tsx - list rendering, filter interactions
  2. ui/src/pages/ExecutionChainDetailPage.test.tsx - detail rendering, entity ref navigation

Option B (Lightweight Collection)

Unit Tests (5 files, ~380 lines):

  1. test/ingestion/chain-assembler.test.ts - chain assembly logic, ID stability, entity ref construction
  2. test/api/execution-chains.test.ts - API endpoint tests (list, get, filters)
  3. test/storage/chain-queries.test.ts - storage adapter query logic
  4. test/storage/chain-upsert.test.ts - upsert behavior (deduplication, updates)
  5. test/ingestion/chain-id-stability.test.ts - stable ID generation across syncs

Integration Tests (2 files, ~160 lines):

  1. test/integration/ingestion/chain-assembly.test.ts - end-to-end chain assembly during sync (ingest graph → verify chains created)
  2. test/integration/api/execution-chains.test.ts - full stack chain API tests

UI Tests (2 files, ~100 lines):

  1. ui/src/pages/ExecutionChainsPage.test.tsx - list page rendering, filters, sorting
  2. ui/src/pages/ExecutionChainDetailPage.test.tsx - detail page, entity ref display, summary display

Option C (Temporal Chains)

Incremental Tests over Option B (2 files, ~180 lines):

  1. test/integration/chain-versioning.test.ts - version creation on changes, expiration, temporal queries
  2. test/api/chain-temporal-queries.test.ts - temporal endpoints (/at/:timestamp, /diff, /history)

Option D (Virtual Entity)

Unit Tests (6 files, ~380 lines):

  1. test/ingestion/chain-entity-assembler.test.ts - entity assembly logic
  2. test/ingestion/path-materializer-chain-filter.test.ts - verify chains filtered from path computation
  3. test/api/entity-filters.test.ts - verify chain entities filterable
  4. test/ui/graph-layout-chains.test.ts - verify chain entities handled in layout
  5. test/ui/entity-detail-chains.test.ts - verify chain entity detail page rendering
  6. test/integration/chain-entity-ingestion.test.ts - end-to-end chain entity creation

Integration Tests (1 file, ~80 lines):

  1. test/integration/api/entity-chain-queries.test.ts - full stack entity API with chain filters

UI Tests (3 files, ~130 lines):

  1. ui/src/pages/EntitiesListPage.test.tsx - verify chains hidden by default
  2. ui/src/pages/EntityDetailPage.test.tsx - chain entity detail rendering
  3. ui/src/components/graph/layout.test.tsx - chain entity graph layout

Concrete Implementation Plan

Recommended Path: Start with Option B, Upgrade to Option C Post-MVP

Rationale:

  • Option A has a fatal temporal query performance trap
  • Option D pollutes the entity type system without clear benefit
  • Option B delivers 90% of value in 2 weeks
  • Option C adds temporal superpowers for forensic analysis in +1 week

Phase 1: Option B (Sprint 1, 2 weeks)

Week 1: Backend

  • Day 1-2: Domain types + chain assembler (~16 hours)
    • src/domain/chains/types.ts - ExecutionChainDoc, ChainEntityRef, ChainSummary
    • src/ingestion/chain-assembler.ts - assembleExecutionChains(), buildChainId(), inferChainType()
    • Unit tests: test/ingestion/chain-assembler.test.ts
  • Day 3-4: Storage layer (~16 hours)
    • src/storage/storage-adapter.ts - ChainQuery interface, chain methods
    • src/storage/mongo/adapter.ts - upsertChain(), getChain(), queryChains(), countChains()
    • src/storage/mongo/collections.ts - add chains collection
    • Unit tests: test/storage/chain-queries.test.ts
  • Day 5: Ingestion integration (~8 hours)
    • src/workers/handlers/sync-ingestion.ts - call chain assembler after path materialization
    • Integration tests: test/integration/ingestion/chain-assembly.test.ts

Week 2: API + UI

  • Day 1-2: API routes (~16 hours)
    • src/api/routes/execution-chains.ts - GET /execution-chains, GET /execution-chains/:id
    • src/api/server.ts - register routes
    • Unit tests: test/api/execution-chains.test.ts
    • Integration tests: test/integration/api/execution-chains.test.ts
  • Day 3-4: UI pages (~20 hours)
    • ui/src/pages/ExecutionChainsPage.tsx - list view with DataTable + filters
    • ui/src/pages/ExecutionChainDetailPage.tsx - detail view with entity refs + summary
    • UI tests: 2 files
  • Day 5: Migration + docs (~10 hours)
    • Schema migration script: scripts/migrate-add-chains-collection.ts
    • Data backfill script: scripts/backfill-execution-chains.ts
    • Documentation: update architecture docs + API docs

Phase 2: Option C (Sprint 2, 1 week)

Week 3: Temporal Tracking

  • Day 1-2: Version types + storage (~14 hours)
    • src/domain/chains/types.ts - ExecutionChainVersionDoc
    • src/storage/storage-adapter.ts - chain version methods
    • src/storage/mongo/adapter.ts - implement version methods
    • Unit tests: version CRUD
  • Day 3: Ingestion versioning (~6 hours)
    • src/workers/handlers/sync-ingestion.ts - detect chain changes, create versions
    • Integration tests: test/integration/chain-versioning.test.ts
  • Day 4: Temporal API (~6 hours)
    • src/api/routes/execution-chains.ts - GET /at/:timestamp, /diff, /history
    • Unit tests: temporal endpoints
  • Day 5: UI + docs (~8 hours)
    • ui/src/pages/ExecutionChainDetailPage.tsx - add temporal comparison tab
    • Documentation: update with temporal query examples

Risk Assessment

Option A Risks

  • High: Temporal query performance unacceptable at scale
  • Medium: Difficult to add chain-specific features later
  • Low: API contract changes if migrating to B/C/D

Option B Risks

  • Low: Schema migration complexity (single collection, straightforward backfill)
  • Low: Chains may become stale if entities change outside sync pipeline (mitigated by sync-driven updates)
  • Negligible: Collection adds ~10-20% to storage size (acceptable)

Option C Risks

  • Medium: Version collection size grows over time (requires retention policy)
  • Low: Diff computation complexity (could have edge cases)
  • Negligible: Ingestion performance (versioning adds ~50ms per chain)

Option D Risks

  • High: Architectural assumptions violated (entity_type no longer source-system-bound)
  • Medium: Hard to isolate chains from entities in queries (filter pollution)
  • Medium: UI/graph layout requires special cases everywhere
  • Low: Migration to B/C difficult (requires entity → collection transform)

Final Recommendation

Ship Option B in Sprint 1 (2 weeks), then Option C in Sprint 2 (1 week).

Justification:

  1. Option A appears simplest but creates technical debt via poor temporal query performance
  2. Option D violates architectural purity (execution_chain is not a source entity) and requires defensive coding throughout
  3. Option B delivers a clean, performant, extensible solution in 2 weeks
  4. Option C adds temporal superpowers for forensic analysis with minimal incremental cost (1 week)

Total Time to Full Solution: 3 weeks Total New Code: ~2,340 lines backend + UI Total New Tests: ~820 lines across 11 files Collections Added: 2 (execution_chains, execution_chain_versions)

Post-MVP Enhancements (deferred):

  • Chain tagging (e.g., "reviewed", "approved", "high-risk")
  • Chain-level findings (e.g., "chain executes with excessive permissions")
  • Chain validation workflow (CISO approves chains before they run)
  • Chain comparison across tenants (benchmark against peer organizations)

Appendix: Neo4j Migration Considerations

If the platform migrates to Neo4j in the future:

Option A (Computed): Trivial - Cypher queries compute chains on-the-fly Option B (Lightweight): Moderate - Map ExecutionChainDoc to :ExecutionChain label with properties Option C (Temporal): Complex - Neo4j temporal queries require custom indexing or external timeline store Option D (Virtual Entity): Easy - execution_chain entities become :ExecutionChain nodes with CONTAINS edges

Verdict: Option A or D are most Neo4j-friendly, but given A's performance issues and D's architectural problems, Option B is still the best long-term choice. Neo4j migration would require ~20 hours to map chains collection to graph nodes.