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 DataTableui/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 teststest/storage/chain-queries.test.ts- chain assembly logictest/ingestion/chain-inference.test.ts- chain type detection
Integration Tests (2 files, ~100 lines):
test/integration/api/execution-chains.test.ts- full stack teststest/integration/temporal-chain-queries.test.ts- temporal query performance
UI Tests (2 files, ~80 lines):
ui/src/pages/ExecutionChainsPage.test.tsx- list page renderingui/src/pages/ExecutionChainDetailPage.test.tsx- detail page interactions
Effort Estimate
| Task | Hours |
|---|---|
| API route + chain assembly logic | 12 |
| Storage adapter methods | 6 |
| Temporal query implementation | 8 |
| UI pages (list + detail) | 16 |
| Unit tests | 8 |
| Integration tests | 6 |
| UI tests | 4 |
| Documentation | 2 |
| Total | 62 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 + filtersui/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 constructiontest/api/execution-chains.test.ts(~120 lines) - API endpoint teststest/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 synctest/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 + filtersui/src/pages/ExecutionChainDetailPage.test.tsx(~50 lines) - detail page + entity ref display
Effort Estimate
| Task | Hours |
|---|---|
| Domain types + chain assembler | 12 |
| Storage adapter methods | 8 |
| Ingestion pipeline integration | 6 |
| API routes | 8 |
| UI pages (list + detail) | 20 |
| Unit tests | 12 |
| Integration tests | 8 |
| UI tests | 6 |
| Schema migration script | 4 |
| Data backfill script | 6 |
| Documentation | 4 |
| Total | 94 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_versionscollection, 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)
| Task | Hours |
|---|---|
| Version types + storage methods | 8 |
| Ingestion versioning logic | 6 |
| Temporal API endpoints | 6 |
| Diff computation logic | 6 |
| UI temporal comparison tab | 8 |
| Unit tests | 6 |
| Integration tests | 6 |
| Documentation | 2 |
| Incremental Total | 48 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
| Task | Hours |
|---|---|
| Entity type modification | 2 |
| Chain entity assembler | 12 |
| Ingestion integration | 8 |
| Path materializer fix | 2 |
| UI entity list filter | 4 |
| Graph layout fix | 6 |
| Entity detail page adaptations | 6 |
| Unit tests | 10 |
| Integration tests | 8 |
| Documentation | 4 |
| Total | 62 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
| Metric | Option A (Computed) | Option B (Lightweight) | Option C (Temporal) | Option D (Virtual Entity) |
|---|---|---|---|---|
| New Files | 3 | 5 | +2 (7 total) | 2 |
| Modified Files | 3 | 5 | +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) | 7 | 9 | +2 (11 total) | 9 |
| New Test LOC | ~330 | ~640 | +180 (820 total) | ~510 |
| New Collections | 0 | 1 | 2 | 0 |
| Schema Migration | None | Required | Required | None |
| Data Backfill | None | Required | Required | Required |
| Effort (hours) | 62 (~1.5 weeks) | 94 (~2.3 weeks) | 142 (~3.5 weeks) | 62 (~1.5 weeks) |
| Ingestion Changes | None | Moderate (1 step added) | Moderate (versioning added) | Significant (entity assembly) |
| API Changes | New routes | New routes | New routes + temporal | Existing routes (filter changes) |
| UI Changes | New pages | New pages | +1 tab | Page adaptations |
| Neo4j Portability | High (no schema) | Moderate (map collection to labels) | Low (versioning complex) | High (chains as nodes) |
| Temporal Query Perf | Poor (N queries/chain) | N/A | Excellent (1 query) | Good (existing infra) |
| Current State Perf | Poor (5-10 queries/chain) | Excellent (1 query) | Excellent (1 query) | Good (1 query + filter) |
| Chain Metadata | Impossible | Easy | Easy | Hard (pollutes entity properties) |
| Architectural Purity | Clean | Clean | Clean | Polluted |
Migration Paths Between Options
A → B (Computed → Lightweight Collection)
Effort: 20 hours Steps:
- Implement Option B (collection + ingestion)
- Run backfill script (one-time compute of all chains)
- Switch API routes to read from collection
- Deploy
Risk: Low (API contract unchanged, just reads from different source)
B → C (Lightweight → Temporal)
Effort: 48 hours Steps:
- Add
execution_chain_versionscollection - Update ingestion to create versions on change
- Add temporal API endpoints
- Backfill historical versions from entity_versions (if needed)
Risk: Low (incremental addition, no breaking changes)
A → D (Computed → Virtual Entity)
Effort: 40 hours Steps:
- Implement Option D (entity type + assembler)
- Run backfill script (create chain entities)
- Update UI filters to hide chains by default
- Fix graph layout + path materializer
Risk: Moderate (entity type assumptions must be audited)
D → B (Virtual Entity → Lightweight Collection)
Effort: 60 hours Steps:
- Query all execution_chain entities
- Transform to ExecutionChainDoc format
- Insert into new execution_chains collection
- Delete chain entities from entities collection
- Update ingestion to use chain assembler instead of entity assembler
- 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:
- Drop
execution_chain_versionscollection - Remove versioning logic from ingestion
- 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):
test/api/execution-chains.test.ts- chain assembly logic, entity traversaltest/api/chain-temporal-queries.test.ts- temporal reconstruction logictest/storage/chain-queries.test.ts- query filter logic
Integration Tests (2 files, ~100 lines):
test/integration/api/execution-chains.test.ts- full stack chain API (seed DB, call API, verify results)test/integration/temporal-chain-performance.test.ts- benchmark temporal query performance (N queries/chain)
UI Tests (2 files, ~80 lines):
ui/src/pages/ExecutionChainsPage.test.tsx- list rendering, filter interactionsui/src/pages/ExecutionChainDetailPage.test.tsx- detail rendering, entity ref navigation
Option B (Lightweight Collection)
Unit Tests (5 files, ~380 lines):
test/ingestion/chain-assembler.test.ts- chain assembly logic, ID stability, entity ref constructiontest/api/execution-chains.test.ts- API endpoint tests (list, get, filters)test/storage/chain-queries.test.ts- storage adapter query logictest/storage/chain-upsert.test.ts- upsert behavior (deduplication, updates)test/ingestion/chain-id-stability.test.ts- stable ID generation across syncs
Integration Tests (2 files, ~160 lines):
test/integration/ingestion/chain-assembly.test.ts- end-to-end chain assembly during sync (ingest graph → verify chains created)test/integration/api/execution-chains.test.ts- full stack chain API tests
UI Tests (2 files, ~100 lines):
ui/src/pages/ExecutionChainsPage.test.tsx- list page rendering, filters, sortingui/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):
test/integration/chain-versioning.test.ts- version creation on changes, expiration, temporal queriestest/api/chain-temporal-queries.test.ts- temporal endpoints (/at/:timestamp, /diff, /history)
Option D (Virtual Entity)
Unit Tests (6 files, ~380 lines):
test/ingestion/chain-entity-assembler.test.ts- entity assembly logictest/ingestion/path-materializer-chain-filter.test.ts- verify chains filtered from path computationtest/api/entity-filters.test.ts- verify chain entities filterabletest/ui/graph-layout-chains.test.ts- verify chain entities handled in layouttest/ui/entity-detail-chains.test.ts- verify chain entity detail page renderingtest/integration/chain-entity-ingestion.test.ts- end-to-end chain entity creation
Integration Tests (1 file, ~80 lines):
test/integration/api/entity-chain-queries.test.ts- full stack entity API with chain filters
UI Tests (3 files, ~130 lines):
ui/src/pages/EntitiesListPage.test.tsx- verify chains hidden by defaultui/src/pages/EntityDetailPage.test.tsx- chain entity detail renderingui/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, ChainSummarysrc/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 methodssrc/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/:idsrc/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 + filtersui/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
- Schema migration script:
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- ExecutionChainVersionDocsrc/storage/storage-adapter.ts- chain version methodssrc/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:
- Option A appears simplest but creates technical debt via poor temporal query performance
- Option D violates architectural purity (execution_chain is not a source entity) and requires defensive coding throughout
- Option B delivers a clean, performant, extensible solution in 2 weeks
- 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.