Skip to main content

OAA Mapping Analysis — Developer Perspective

Author: Developer (Implementation Specialist) Date: 2026-02-13 (Round 4) Context: Critical analysis of OAA alignment for automation chains — implementation costs, types, code impact, migration paths


Executive Summary

This document analyzes three approaches for mapping SecurityV0's automation chain concept to the Open Authorization API (OAA) standard, with focus on implementation feasibility, code changes, and migration complexity.

The OAA Question: The founder observed that OAA treats automations as Applications rather than Identities, and suggested autonomous execution could be modeled as either "an application" or "a collection of applications, identities, roles, data modifications."

Core Trade-Off: Round 3 recommended a new execution_chains collection (~94h). Round 4 asks: should chains map to OAA's Application model instead, potentially changing the platform's core entity types?

Recommendation: Approach C (Hybrid: Chains + OAA Projection) — Implement Round 3's execution_chains collection as planned, then add a thin OAA export layer that projects chains as OAA Applications. This gives clean separation, preserves Round 3's investment, and provides OAA compatibility without polluting the entity type system.

Key Finding: Pure OAA alignment (treating automations as Applications in the entities collection) creates architectural pollution, requires defensive coding throughout the codebase, and offers no clear benefit over a projection layer.


Background: OAA Data Model

What OAA Provides

Open Authorization API (OAA) is Veza's standard for representing authorization metadata. It has three core concepts:

OAA ConceptWhat It RepresentsExample
ApplicationA container with local users, groups, roles, resources, and permissions"Support Portal" app with bob@example.com user, "admins" group, "owner" permission
ResourceA nestable component within an application that can be acted uponA table, file, API endpoint, workflow — can contain child resources
PermissionA capability expressed as 10 canonical typesDataRead, DataWrite, DataDelete, DataCreate, MetadataRead, MetadataWrite, NonData, OwnershipAssignment, ResourceAdmin, AccountAdmin

10 Canonical Permissions

OAA PermissionSecurityV0 EquivalentMeaning
DataReadreadView/query data
DataWriteupdateModify data
DataDeletedeleteRemove data
DataCreatecreateCreate new data
MetadataReadread (metadata)Read schema/structure
MetadataWriteadminModify schema/structure
NonDataexecuteNon-data actions (trigger, run)
OwnershipAssignmentdelegateGrant ownership/access to others
ResourceAdminadminResource-level administration
AccountAdminadminAccount/tenant-level administration

Example OAA Payload

{
"applications": [
{
"name": "Support Portal",
"application_type": "support_portal",
"local_users": [
{ "name": "bob", "identities": ["bob@example.com"] }
],
"local_groups": [
{ "name": "admins" }
]
}
],
"permissions": [
{
"name": "admin",
"permission_type": ["DataWrite", "DataRead", "MetadataWrite"]
}
],
"identity_to_permissions": [
{
"identity": "admins",
"identity_type": "local_group",
"application_permissions": [
{ "application": "Support Portal", "permission": "admin" }
]
}
]
}

The Mapping Challenge

Current SecurityV0 Model (Post Round 3)

After Round 3's decision, the platform has:

  1. Entity types (6): identity, owner, role, permission, resource, credential
  2. Identity subtypes for automation: business_rule, flow_designer_flow, scheduled_job, system_execution
  3. Execution chains collection: Separate collection with stable IDs, entity refs, aggregate summaries
  4. Relationships: RUNS_AS, TRIGGERS_ON, EXECUTES_ON, AUTHENTICATES_TO, etc.

Automation chain example:

Business Rule (Identity subtype)
-[TRIGGERS_ON]-> Incident table (Resource)
-[RUNS_AS]-> SP (Identity)
-[AUTHENTICATES_TO]-> ServiceNow integration user (Identity)
-[EXECUTES_ON]-> Microsoft Graph API (Resource)

OAA's View

OAA would model this as:

  • Application: The automation itself (Business Rule or Flow)
  • Resources: The trigger table (incident) and destination (Graph API)
  • Identities: The run-as service principal
  • Permissions: What the automation can do (DataRead, DataWrite, etc.)

Key difference: In OAA, the automation is NOT an identity — it's an application container. The service principal it runs as is the identity.


Platform Context

Current Architecture (Phase 8)

Codebase:

  • Backend: ~15,000 LOC TypeScript
  • Frontend: ~8,000 LOC React
  • Test coverage: 205 unit + 84 integration + 19 UI tests

Key files:

  • src/domain/entities/types.ts — Entity type definitions (~150 lines)
  • src/ingestion/types.ts — NormalizedNodeType enum (8 lines)
  • src/storage/mongo/adapter.ts — StorageAdapter (~750 lines)
  • src/workers/handlers/sync-ingestion.ts — Sync pipeline (~190 lines)
  • src/ingestion/path-materializer.ts — Execution path computation (~200 lines)
  • src/ingestion/graph-transformer.ts — NormalizedGraph → EntityDoc (~300 lines)

Collections (10):

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"

NormalizedNodeType (8):

type NormalizedNodeType =
| "autonomous_identity"
| "human_identity"
| "role"
| "permission"
| "resource"
| "credential"
| "execution_evidence";

Approach A: Automation as Application Entity Type

Concept

Add "application" to the EntityType enum. Treat automation chains as first-class application entities in the entities collection.

TypeScript Changes

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

// AFTER
type EntityType = "identity" | "owner" | "role" | "permission" | "resource" | "credential" | "application"

// NormalizedNodeType changes
type NormalizedNodeType =
| "autonomous_identity"
| "human_identity"
| "role"
| "permission"
| "resource"
| "credential"
| "execution_evidence"
| "application" // NEW

EntityDoc Structure for Applications

{
_id: "app-chain-abc123",
tenant_id: "default",
entity_type: "application",
source_system: "sv0-platform", // Synthetic source
source_id: "automation-azuregraphrouter",

properties: {
display_name: "AzureGraphRouter Incident Routing",
status: "active",
application_type: "execution_chain", // OAA application_type

// Chain metadata
anchor_entity_id: "uuid-br-abc",
trigger: "incident table insert",
destination: "graph.microsoft.com",
egress_category: "external",
blast_radius_domains: ["identity_platform"],
ownership_status: "orphaned",

// OAA-specific properties
local_users: [], // Not applicable
local_groups: [], // Not applicable
resources: [
{ name: "incident", resource_type: "table" },
{ name: "graph.microsoft.com", resource_type: "api" }
]
},

relationships: [
{ type: "CONTAINS", target_id: "uuid-trigger-incident", properties: { role: "trigger" } },
{ type: "CONTAINS", target_id: "uuid-br-abc", properties: { role: "entry_point" } },
{ type: "CONTAINS", target_id: "uuid-sp-xyz", properties: { role: "identity" } },
{ type: "CONTAINS", target_id: "uuid-rest-graph", properties: { role: "destination" } }
],

execution_paths: [], // Not applicable for applications
sync_version: 42,
last_synced_at: Date,
created_at: Date,
updated_at: Date
}

What Breaks

1. Path Materializer

// src/ingestion/path-materializer.ts (~line 50)

// BEFORE
export async function materializeExecutionPaths(
identityIds: string[],
tenantId: string,
storageAdapter: StorageAdapter
): Promise<PathResult> {
for (const identityId of identityIds) {
const identity = await storageAdapter.getEntity(tenantId, identityId);
if (!identity || identity.entity_type !== "identity") continue;
// ... compute paths
}
}

// AFTER — Must filter out application entities
export async function materializeExecutionPaths(
identityIds: string[],
tenantId: string,
storageAdapter: StorageAdapter
): Promise<PathResult> {
for (const identityId of identityIds) {
const identity = await storageAdapter.getEntity(tenantId, identityId);
// Filter out applications (execution chains)
if (!identity || identity.entity_type !== "identity") continue;
if (identity.entity_type === "application") continue; // NEW
// ... compute paths
}
}

Impact: 3 lines changed, but every future path computation must remember to filter applications.

2. Entity List UI

// ui/src/pages/EntitiesListPage.tsx

// BEFORE — Shows all entities (identities, roles, resources)
// User filters by entity_type dropdown

// AFTER — Must hide applications by default
// entity_type filter now includes "application"
// Default filter: entity_type !== "application"
// User can explicitly show applications if needed

Impact: ~15 lines added to default filter logic. Risk: applications appear in entity list if filter is removed.

3. Graph Layout

// ui/src/components/graph/layout.ts (~line 120)

// BEFORE — 7-layer execution ranking
function assignLayer(entity: EntityDoc): number {
switch (entity.entity_type) {
case "identity": return 1;
case "role": return 2;
case "permission": case "resource": return 3;
case "credential": return 0;
case "owner": return -1;
default: return 0;
}
}

// AFTER — Must handle application type
function assignLayer(entity: EntityDoc): number {
switch (entity.entity_type) {
case "identity": return 1;
case "role": return 2;
case "permission": case "resource": return 3;
case "credential": return 0;
case "owner": return -1;
case "application": return 4; // NEW — or group as container?
default: return 0;
}
}

Impact: ~5 lines added, but conceptually unclear. Applications are containers, not nodes in the execution graph.

4. Subgraph Queries

// src/storage/mongo/adapter.ts — getSubgraph()

// CURRENT — Traverses relationships for execution paths
// Uses CONTAINS for ownership, HAS_ROLE for authority
// CONTAINS is NEW and only used by applications

// AFTER — Must handle CONTAINS edges
// Reverse lookup: "Show me all applications that contain this entity"
// Forward lookup: "Show me what this application contains"

Impact: CONTAINS relationship is novel. No other entity type uses it. Requires special-case logic in graph traversal.

5. Entity Detail Page

// ui/src/pages/EntityDetailPage.tsx

// CURRENT — Shows 5 tabs:
// Overview, Relationships, Timeline, Evidence, Findings

// AFTER — If user navigates to an application entity:
// - Overview: application properties (works)
// - Relationships: CONTAINS edges (works, but UI labels may be wrong)
// - Timeline: events on application entity (works)
// - Evidence: execution_evidence? (applications don't execute, their contained identities do)
// - Findings: findings targeting application? (or its contained identities?)

Impact: ~30 lines to conditionally render tabs. Evidence and Findings tabs require new logic to aggregate from contained entities.

6. Sync Ingestion Pipeline

// src/workers/handlers/sync-ingestion.ts (~line 132)

// BEFORE — Path materialization for identities
const affectedIdentityIds = entities
.filter(e => e.entity_type === "identity")
.map(e => e._id);

const pathResult = await materializeExecutionPaths(
affectedIdentityIds,
tenantId,
storageAdapter
);

// AFTER — Must also assemble application entities
const affectedIdentityIds = entities
.filter(e => e.entity_type === "identity")
.map(e => e._id);

const pathResult = await materializeExecutionPaths(
affectedIdentityIds,
tenantId,
storageAdapter
);

// NEW — Assemble application entities from automation identities
const automationIds = entities
.filter(e => e.entity_type === "identity" && isAutomationSubtype(e.properties.identitySubtype))
.map(e => e._id);

const applications = await assembleApplications(
automationIds,
tenantId,
syncVersion,
storageAdapter
);

await storageAdapter.upsertEntities(applications);

Impact: ~40 lines added to sync pipeline. New assembleApplications() function needed (~180 lines, similar to Round 3's chain assembler).

Code Changes Summary

Modified Files (10):

  1. src/domain/entities/types.ts — Add "application" entity type (~2 lines)
  2. src/ingestion/types.ts — Add "application" NormalizedNodeType (~2 lines)
  3. src/ingestion/graph-transformer.ts — Handle application nodes (~30 lines)
  4. src/ingestion/application-assembler.ts — NEW (~180 lines)
  5. src/workers/handlers/sync-ingestion.ts — Integrate application assembly (~40 lines)
  6. src/ingestion/path-materializer.ts — Filter out applications (~3 lines)
  7. ui/src/pages/EntitiesListPage.tsx — Hide applications by default (~15 lines)
  8. ui/src/components/graph/layout.ts — Handle application layer (~5 lines)
  9. ui/src/pages/EntityDetailPage.tsx — Conditional tabs for applications (~30 lines)
  10. src/storage/mongo/adapter.ts — CONTAINS edge handling in subgraph (~20 lines)

New Files (2):

  1. src/ingestion/application-assembler.ts (~180 lines)
  2. test/ingestion/application-assembler.test.ts (~150 lines)

Total New Lines: ~477 backend + ~50 UI = ~527 lines

Effort Estimate

TaskHours
Entity type modification2
NormalizedNodeType addition2
Application assembler12
Ingestion integration8
Path materializer filter2
Graph transformer changes6
UI entity list filter4
Graph layout fix4
Entity detail page adaptations6
CONTAINS edge handling6
Unit tests12
Integration tests8
Documentation4
Total76 hours (~2 weeks)

Pros

  • Direct OAA alignment — applications are application entities
  • Reuses existing entity infrastructure (versioning, events, temporal)
  • No new collections
  • Chains get audit trail for free

Cons

  • Architectural pollution: application is not a source-system entity (violates ADR-002)
  • Novel relationship type: CONTAINS not used by any other entity
  • Defensive coding required: Path materializer, graph layout, entity filters all need special cases
  • Conceptual confusion: Applications are containers, not nodes in execution paths
  • Risk of leakage: Applications mixed with real entities in queries (must filter everywhere)
  • Evidence/findings ambiguity: Does an application have execution evidence, or do its contained identities?
  • Hard to add application-specific features: e.g., application-level compliance status, validation workflows

Migration Path

  • A → Round 3 Option B: Moderate effort — query all application entities, transform to ExecutionChainDoc, create chains collection, drop application entities
  • A → Approach C (Projection): Easy — applications already exist, just add export layer

Approach B: Resource + DataObject Types (Fine-Grained OAA Alignment)

Concept

Add "data_object" entity type to represent data acted upon (tables, files, APIs). Automation chains become execution flows: Resource(trigger) → Identity(automation) → Resource(target).

TypeScript Changes

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

// AFTER
type EntityType = "identity" | "owner" | "role" | "permission" | "resource" | "credential" | "data_object"

// NormalizedNodeType changes
type NormalizedNodeType =
| "autonomous_identity"
| "human_identity"
| "role"
| "permission"
| "resource"
| "credential"
| "execution_evidence"
| "data_object" // NEW

What Changes

Resource vs Data Object Distinction

TypeWhat It RepresentsExample
resourceAutomation artifacts, workflows, scriptsBusiness Rule, Flow, Script Include, Scheduled Job
data_objectData acted uponIncident table, Graph API, S3 bucket

Current model: Everything is a "resource" (tables, APIs, automations). OAA-aligned model: Automations stay as identities (or become resources), data becomes data_objects.

Entity Structure

// Data object (incident table)
{
_id: "dataobj-incident",
tenant_id: "default",
entity_type: "data_object",
source_system: "servicenow",
source_id: "table-incident",

properties: {
display_name: "incident",
data_object_type: "table",
business_domain: "it_ops",
sensitivity: "internal",
contains_pii: false
},

relationships: [
{ type: "ACCESSIBLE_VIA", target_id: "perm-incident-write" }
]
}

// Automation as resource
{
_id: "res-business-rule-abc",
entity_type: "resource",
source_system: "servicenow",
source_id: "br-abc123",

properties: {
display_name: "AzureGraphRouter",
resource_type: "business_rule",
status: "active"
},

relationships: [
{ type: "TRIGGERS_ON", target_id: "dataobj-incident" },
{ type: "RUNS_AS", target_id: "identity-sp-xyz" },
{ type: "WRITES_TO", target_id: "dataobj-graph-api" }
]
}

New Relationships

RelationshipFrom → ToMeaning
READSIdentity/Resource → DataObject"This entity reads data from this object"
WRITESIdentity/Resource → DataObject"This entity writes data to this object"
TRIGGERS_ONResource → DataObject"This automation is triggered by this data object"

What Breaks

1. Existing Resource Documents

Problem: The platform already has ~1,000+ resource documents (ServiceNow tables, Entra APIs). Now "resource" means something different.

Migration required:

  1. Query all existing entity_type: "resource" documents
  2. Classify: Is this a data object (table, API) or an automation resource (business rule, flow)?
  3. For data objects: Change entity_type to "data_object", update relationships
  4. For automation resources: Keep as "resource", update resource_type property

Effort: ~40 hours (migration script, backfill, validation, rollback plan)

2. Permission Relationships

BEFORE:

Permission -[APPLIES_TO]-> Resource (incident table)

AFTER:

Permission -[APPLIES_TO]-> DataObject (incident table)

Impact: All existing APPLIES_TO edges that target data must be updated. Queries that traverse APPLIES_TO must handle both resource and data_object targets.

3. Execution Paths

BEFORE:

execution_paths: [
{
resource_id: "uuid-res-hr-case",
resource_name: "hr_case",
business_domain: "hr",
sensitivity: "confidential"
}
]

AFTER:

execution_paths: [
{
data_object_id: "uuid-dataobj-hr-case", // Renamed
data_object_name: "hr_case",
business_domain: "hr",
sensitivity: "confidential"
}
]

Impact: Materialized paths use different field names. All path queries must be updated. Temporal queries (entity_versions) have inconsistent field names pre/post migration.

4. Findings

BEFORE:

finding: {
affected_resources: ["uuid-res-hr-case", "uuid-res-incident"]
}

AFTER:

finding: {
affected_data_objects: ["uuid-dataobj-hr-case", "uuid-dataobj-incident"]
}

Impact: Finding schema changes. Evidence packs must map to new field names.

Code Changes Summary

Modified Files (15):

  1. src/domain/entities/types.ts — Add data_object type (~2 lines)
  2. src/ingestion/types.ts — Add data_object NormalizedNodeType (~2 lines)
  3. src/ingestion/graph-transformer.ts — Handle data_object nodes, resource reclassification (~60 lines)
  4. src/ingestion/path-materializer.ts — Update path computation for data_objects (~40 lines)
  5. src/storage/mongo/adapter.ts — Update queries to handle data_object (~30 lines)
  6. src/domain/findings/types.ts — Rename affected_resources → affected_data_objects (~5 lines)
  7. ui/src/pages/EntitiesListPage.tsx — Add data_object filter option (~5 lines)
  8. ui/src/components/graph/layout.ts — Handle data_object layer (~5 lines)
  9. ui/src/pages/EntityDetailPage.tsx — Render data_object entities (~20 lines)
  10. scripts/migrate-resource-to-dataobject.ts — NEW migration script (~200 lines)
  11. src/workers/handlers/sync-ingestion.ts — Handle reclassification (~30 lines)
  12. 4 test files — Update assertions for new type (~100 lines total)

Total New Lines: ~499 backend + ~30 UI + ~200 migration = ~729 lines

Effort Estimate

TaskHours
Entity type modification2
NormalizedNodeType addition2
Graph transformer reclassification logic12
Path materializer updates8
Storage adapter query updates6
Finding schema changes4
UI updates (filters, layout, detail)8
Migration script16
Data backfill + validation16
Unit tests12
Integration tests10
Rollback plan6
Documentation4
Total106 hours (~2.7 weeks)

Pros

  • Fine-grained OAA alignment — separates data from automation resources
  • Clearer semantics: "writes to data object" vs "applies to resource"
  • Better future-proofs for OAA export (direct mapping)

Cons

  • Breaking change: Requires migrating all existing resource documents
  • High migration risk: 1,000+ documents must be reclassified correctly
  • Temporal query complexity: Pre/post migration, field names differ (resource_id vs data_object_id)
  • Relationship updates: All APPLIES_TO edges must be updated
  • Rollback difficulty: Hard to roll back once data is migrated
  • Conceptual overhead: Two new types (application + data_object) vs one collection (chains)

Migration Path

  • B → Round 3 Option B: Hard — must reverse data_object → resource changes, drop application logic
  • B → Approach A: Moderate — convert data_objects back to resources, add application type

Approach C: Hybrid (Chains + OAA Projection)

Concept

Keep Round 3's execution_chains collection as-is, but add a thin OAA export layer that projects chains as OAA Applications on demand.

Architecture

STORAGE LAYER (MongoDB)
======================
entities collection (6 types): identity, owner, role, permission, resource, credential
execution_chains collection: chain docs with entity refs, summaries

PROJECTION LAYER
================
OAA Export API: GET /api/v1/oaa/export

Reads execution_chains collection

Maps each chain to OAA Application format

Returns OAA-compliant JSON payload

No Type Changes

Zero changes to existing EntityType or NormalizedNodeType enums.

New OAA Types (Projection Only)

// New file: src/oaa/types.ts (~150 lines)

export interface OAAApplication {
name: string;
application_type: string;
description?: string;
local_users: OAALocalUser[];
local_groups: OAALocalGroup[];
}

export interface OAALocalUser {
name: string;
identities: string[];
groups?: string[];
is_active: boolean;
last_login_at?: string;
}

export interface OAALocalGroup {
name: string;
created_at?: string;
}

export interface OAAPermission {
name: string;
permission_type: OAAPermissionType[];
}

export type OAAPermissionType =
| "DataRead"
| "DataWrite"
| "DataDelete"
| "DataCreate"
| "MetadataRead"
| "MetadataWrite"
| "NonData"
| "OwnershipAssignment"
| "ResourceAdmin"
| "AccountAdmin";

export interface OAAIdentityToPermission {
identity: string;
identity_type: "local_user" | "local_group";
application_permissions: {
application: string;
permission: string;
resources?: string[];
}[];
}

export interface OAAPayload {
applications: OAAApplication[];
permissions: OAAPermission[];
identity_to_permissions: OAAIdentityToPermission[];
}

OAA Projection Logic

// New file: src/oaa/projector.ts (~200 lines)

export async function projectChainsToOAA(
tenantId: string,
storageAdapter: StorageAdapter
): Promise<OAAPayload> {
const chains = await storageAdapter.queryChains(tenantId, { limit: 0 });
const applications: OAAApplication[] = [];
const permissions: OAAPermission[] = [];
const identityToPermissions: OAAIdentityToPermission[] = [];

for (const chain of chains) {
// Map chain to OAA Application
applications.push({
name: chain.name,
application_type: "execution_chain",
description: `${chain.summary.trigger}${chain.summary.destination}`,
local_users: [], // Chains don't have local users
local_groups: [] // Chains don't have local groups
});

// Extract identity refs from chain
const authRef = chain.entity_refs.find(r => r.role === "auth");
if (authRef) {
const authEntity = await storageAdapter.getEntity(tenantId, authRef.entity_id);
if (authEntity) {
// Map identity's permissions to OAA permissions
const mappedPerms = mapSecurityV0PermissionsToOAA(authEntity.execution_paths);
permissions.push(...mappedPerms);

// Link identity to permissions
identityToPermissions.push({
identity: authEntity.properties.display_name as string,
identity_type: "local_user", // Treat identities as local users in OAA
application_permissions: mappedPerms.map(p => ({
application: chain.name,
permission: p.name
}))
});
}
}
}

return { applications, permissions, identity_to_permissions: identityToPermissions };
}

function mapSecurityV0PermissionsToOAA(
paths: ExecutionPath[]
): OAAPermission[] {
const permSet = new Map<string, Set<OAAPermissionType>>();

for (const path of paths) {
for (const action of path.actions) {
const oaaTypes = mapActionToOAATypes(action, path.sensitivity);
const key = `${path.resource_name}_${action}`;
if (!permSet.has(key)) {
permSet.set(key, new Set());
}
oaaTypes.forEach(t => permSet.get(key)!.add(t));
}
}

return [...permSet.entries()].map(([name, types]) => ({
name,
permission_type: [...types]
}));
}

function mapActionToOAATypes(
action: string,
sensitivity: string
): OAAPermissionType[] {
switch (action) {
case "read": return ["DataRead"];
case "update": return ["DataWrite"];
case "create": return ["DataCreate"];
case "delete": return ["DataDelete"];
case "execute": return ["NonData"];
case "admin": return ["ResourceAdmin", "MetadataWrite"];
case "delegate": return ["OwnershipAssignment"];
default: return [];
}
}

API Route

// New file: src/api/routes/oaa.ts (~80 lines)

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

router.get("/api/v1/oaa/export", 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 payload = await projectChainsToOAA(tenantId, storageAdapter);
res.status(200).json(payload);
});

return router;
}

Code Changes Summary

New Files (4):

  1. src/oaa/types.ts (~150 lines) — OAA type definitions
  2. src/oaa/projector.ts (~200 lines) — Projection logic
  3. src/api/routes/oaa.ts (~80 lines) — OAA export endpoint
  4. test/oaa/projector.test.ts (~120 lines) — Projection tests

Modified Files (1):

  1. src/api/server.ts — Register OAA routes (~2 lines)

Total New Lines: ~550 backend + 0 UI = ~550 lines

Round 3 Deliverables: Still needed (~1,710 lines for chains collection + 94h effort)

Total (Round 3 + OAA): ~2,260 lines

Effort Estimate (Incremental over Round 3)

TaskHours
OAA type definitions4
Projection logic (chains → OAA)12
Permission mapping (SecurityV0 → OAA)6
API route4
Unit tests8
Integration tests6
Documentation4
Incremental Total44 hours (~1.1 weeks)
Total (Round 3 + OAA)138 hours (~3.5 weeks)

Pros

  • Clean separation: Chains are platform-internal, OAA is export format
  • Zero entity type pollution: No application or data_object types added
  • Respects Round 3 investment: Builds on top of chains collection
  • Flexible: Projection can change without affecting core model
  • No migration risk: Additive only, no existing data changes
  • Future-proof: Can add other export formats (SCIM, Neo4j) without changing core model

Cons

  • Not "native" OAA: Applications don't exist as entities, only as projections
  • Incremental effort: Adds ~44h on top of Round 3's 94h (total 138h)
  • Two representations: Chains internally, OAA externally

Migration Path

  • C → Round 3 Option B: Already there (chains collection exists)
  • C → Approach A: Easy — add application entity type, populate from chains, optionally drop projection layer

Implementation Comparison Matrix

MetricApproach A (Application Entity)Approach B (Resource + DataObject)Approach C (Chains + OAA Projection)
New Entity Types1 (application)1 (data_object)0
Breaking ChangesNo (additive)Yes (resource → data_object migration)No (additive)
Files Modified10151
New Files21 migration script4
New LOC (backend)~477~499 + ~200 migration~550
Total LOC (backend + Round 3)~477 (no Round 3 chains)~699~2,260 (includes Round 3)
Collections Added001 (Round 3 chains)
Migration RequiredNoYes (1,000+ docs)No
Effort (hours)76106138 (94 Round 3 + 44 OAA)
Rollback RiskModerateHighLow
Architectural PurityPolluted (apps as entities)Complex (2 new types)Clean (projection layer)
Defensive CodingRequired (filters everywhere)Required (dual resource types)Not required
OAA AlignmentNative (apps are entities)Native (fine-grained types)Export-layer only
Neo4j PortabilityModerate (apps as nodes)Moderate (2 node types)Good (chains map to app subgraph)

Impact Analysis: What Code Breaks?

Approach A (Application Entity)

Files requiring defensive coding:

  1. src/ingestion/path-materializer.ts — Filter out applications (~3 lines)
  2. ui/src/pages/EntitiesListPage.tsx — Hide applications by default (~15 lines)
  3. ui/src/components/graph/layout.ts — Handle application layer (~5 lines)
  4. src/storage/mongo/adapter.ts — CONTAINS edge handling (~20 lines)

Ongoing risk: Every future developer querying entities must remember to filter entity_type !== "application" or they'll get unexpected results.

Approach B (Resource + DataObject)

Breaking changes:

  1. All existing resource documents with resource_type: "table" must migrate to entity_type: "data_object"
  2. All APPLIES_TO edges targeting data must be updated
  3. All execution_paths must rename resource_iddata_object_id
  4. All findings must rename affected_resourcesaffected_data_objects

Temporal query impact: Pre-migration entity_versions use resource_id, post-migration uses data_object_id. Diff queries spanning the migration date must handle both.

Approach C (Chains + OAA Projection)

Breaking changes: None. Defensive coding: None. Temporal impact: None (chains are new, no existing data).


Round 3 vs Round 4 Decision

Option Comparison

OptionApproachEffortValueRiskOAA Alignment
Round 3 Option Bexecution_chains collection94hHigh (chain identity, tracking, diff)LowNone
Approach AApplication entity type76hModerate (OAA native apps)Moderate (pollution)Native
Approach BResource + DataObject106hLow (fine-grained OAA)High (migration)Native
Approach CChains + OAA projection138h (94+44)High (chains + OAA export)LowExport layer

Effort-to-Value Ratio

ApproachEffortValue DeliveredRatio
Round 3 Option B94hChain identity, tracking, temporal1.0x
Approach A76hOAA apps, but architectural pollution0.7x
Approach B106hFine-grained OAA, but migration risk0.5x
Approach C138hChains + OAA export, clean separation1.2x

Recommendation

Ship Approach C: Round 3's chains collection + OAA projection layer.

Justification:

  1. Respects Round 3 investment: ~94h of chains work is still valuable
  2. Clean architecture: No entity type pollution, no defensive coding required
  3. OAA compatibility: Export layer provides OAA format when needed
  4. Low risk: Additive only, no migration, easy rollback
  5. Flexible: Can add SCIM, Neo4j, or other export formats without changing core model
  6. Best effort-to-value: Delivers chains + OAA for ~138h total (vs 76h for polluted apps or 106h for risky migration)

Concrete Implementation (Approach C)

Phase 1: Chains Collection (Round 3 — 94h, 2-3 weeks)

See Round 3 Developer analysis for full details. Summary:

Week 1: Backend

  • Domain types: ExecutionChainDoc, ChainEntityRef, ChainSummary
  • Chain assembler: assembleExecutionChains(), buildChainId()
  • Storage adapter: upsertChain(), getChain(), queryChains()
  • Ingestion integration: call assembler after path materialization

Week 2: API + UI

  • API routes: GET /execution-chains, GET /execution-chains/:id
  • UI pages: ExecutionChainsPage (list), ExecutionChainDetailPage (detail)
  • Migration: backfill-execution-chains.ts

Phase 2: OAA Projection (Incremental — 44h, 1 week)

Day 1-2: OAA Types + Mapping (16h)

// src/oaa/types.ts
export interface OAAApplication {
name: string;
application_type: string;
local_users: OAALocalUser[];
local_groups: OAALocalGroup[];
}

export type OAAPermissionType =
| "DataRead" | "DataWrite" | "DataDelete" | "DataCreate"
| "MetadataRead" | "MetadataWrite" | "NonData"
| "OwnershipAssignment" | "ResourceAdmin" | "AccountAdmin";

export interface OAAPayload {
applications: OAAApplication[];
permissions: OAAPermission[];
identity_to_permissions: OAAIdentityToPermission[];
}

Mapping logic:

// src/oaa/permission-mapper.ts

export function mapSecurityV0ToOAA(action: string): OAAPermissionType[] {
const mapping: Record<string, OAAPermissionType[]> = {
"read": ["DataRead"],
"update": ["DataWrite"],
"create": ["DataCreate"],
"delete": ["DataDelete"],
"execute": ["NonData"],
"admin": ["ResourceAdmin", "MetadataWrite"],
"delegate": ["OwnershipAssignment"]
};
return mapping[action] ?? [];
}

Day 3-4: Projection Logic (16h)

// src/oaa/projector.ts

export async function projectChainsToOAA(
tenantId: string,
storageAdapter: StorageAdapter
): Promise<OAAPayload> {
const chains = await storageAdapter.queryChains(tenantId, { limit: 0 });
const applications: OAAApplication[] = [];
const permissions: OAAPermission[] = [];
const identityToPermissions: OAAIdentityToPermission[] = [];

for (const chain of chains) {
// 1. Map chain to OAA Application
applications.push({
name: chain.name,
application_type: "execution_chain",
description: `${chain.summary.trigger}${chain.summary.destination}`,
local_users: [],
local_groups: []
});

// 2. Extract auth identity
const authRef = chain.entity_refs.find(r => r.role === "auth");
if (!authRef) continue;

const authEntity = await storageAdapter.getEntity(tenantId, authRef.entity_id);
if (!authEntity) continue;

// 3. Map execution paths to OAA permissions
for (const path of authEntity.execution_paths) {
const permName = `${path.resource_name}_${path.actions.join("_")}`;
const oaaTypes = path.actions.flatMap(a => mapSecurityV0ToOAA(a));

permissions.push({
name: permName,
permission_type: [...new Set(oaaTypes)]
});

identityToPermissions.push({
identity: authEntity.properties.display_name as string,
identity_type: "local_user",
application_permissions: [{
application: chain.name,
permission: permName,
resources: [path.resource_name]
}]
});
}
}

return { applications, permissions, identity_to_permissions: identityToPermissions };
}

Day 5: API Route + Tests (12h)

// src/api/routes/oaa.ts

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

router.get("/api/v1/oaa/export", 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 format = req.query.format as string ?? "json";

if (format === "json") {
const payload = await projectChainsToOAA(tenantId, storageAdapter);
res.status(200).json(payload);
} else {
res.status(400).json({ error: { code: "INVALID_FORMAT", message: "Only 'json' format is supported", status: 400 } });
}
});

return router;
}

Tests:

// test/oaa/projector.test.ts

describe("OAA Projector", () => {
it("projects chains to OAA applications", async () => {
const payload = await projectChainsToOAA(tenantId, adapter);

expect(payload.applications).toHaveLength(2);
expect(payload.applications[0]).toEqual({
name: "AzureGraphRouter",
application_type: "execution_chain",
description: "incident table insert → graph.microsoft.com",
local_users: [],
local_groups: []
});

expect(payload.permissions).toContainEqual({
name: "hr_case_read_update",
permission_type: ["DataRead", "DataWrite"]
});

expect(payload.identity_to_permissions).toContainEqual({
identity: "SN-Integration-Prod",
identity_type: "local_user",
application_permissions: [{
application: "AzureGraphRouter",
permission: "hr_case_read_update",
resources: ["hr_case"]
}]
});
});

it("handles chains with no auth identity", async () => {
// Chain with no RUNS_AS → no identity → no permissions
const payload = await projectChainsToOAA(tenantId, adapter);
expect(payload.identity_to_permissions).not.toContainEqual(
expect.objectContaining({ identity: "orphaned-chain" })
);
});
});

Migration Strategy

Approach A → Approach C

Scenario: We shipped Approach A (application entity type), but now want clean separation.

Steps:

  1. Create execution_chains collection
  2. Query all entity_type: "application" entities
  3. Transform to ExecutionChainDoc format
  4. Insert into chains collection
  5. Add OAA projection layer (reads from chains)
  6. Optionally: Drop application entities, clean up filters

Effort: ~60h (migration script, backfill, validation, cleanup) Risk: Moderate (data transform required, temporal queries affected)

Approach B → Approach C

Scenario: We shipped Approach B (resource + data_object), but want to undo the migration.

Steps:

  1. Query all entity_type: "data_object" entities
  2. Change back to entity_type: "resource"
  3. Update all APPLIES_TO edges
  4. Update all execution_paths (data_object_id → resource_id)
  5. Revert findings schema
  6. Implement Round 3 chains collection
  7. Add OAA projection layer

Effort: ~80h (reverse migration + Round 3 implementation) Risk: High (reverse migration can lose data, temporal query complexity)

Approach C → Approach A

Scenario: We shipped Approach C (chains + OAA), but want native OAA applications.

Steps:

  1. Keep execution_chains collection (no changes)
  2. Add "application" entity type
  3. Generate application entities from chains
  4. Update UI to show applications
  5. Optionally: Drop OAA projection layer

Effort: ~40h (application type + entity generation) Risk: Low (chains already exist, applications are additive)


Risk Assessment

Approach A Risks

  • Medium: Architectural pollution (application as entity violates ADR-002)
  • Medium: Defensive coding required throughout codebase (filters, special cases)
  • Low: CONTAINS relationship novel, requires graph traversal updates
  • Medium: UI confusion (applications in entity list by default until filtered)

Approach B Risks

  • High: Migration complexity (1,000+ documents, relationship updates, schema changes)
  • High: Rollback difficulty (reverse migration can lose data)
  • Medium: Temporal query complexity (field names differ pre/post migration)
  • Low: Conceptual overhead (resource vs data_object distinction)

Approach C Risks

  • Low: Incremental effort (44h on top of 94h Round 3)
  • Low: No breaking changes (additive only)
  • Negligible: Projection layer complexity (straightforward mapping)
  • Negligible: OAA export not "native" (applications don't exist as entities)

Neo4j Compatibility

Approach A (Application Entity)

Neo4j mapping:

  • Application entities → :Application nodes
  • CONTAINS edges → :CONTAINS relationships
  • Graph query: MATCH (app:Application)-[:CONTAINS]->(entity) RETURN app, entity

Portability: Good (applications are nodes with explicit edges).

Approach B (Resource + DataObject)

Neo4j mapping:

  • Resource entities → :Resource nodes
  • DataObject entities → :DataObject nodes
  • Dual-typed resources: must handle both labels

Portability: Moderate (two node types for similar concepts).

Approach C (Chains + OAA Projection)

Neo4j mapping:

  • Chains → virtual :ExecutionChain nodes (projected from entity refs)
  • Entity refs → :CONTAINS edges
  • Graph query: Construct chain subgraph from entity relationships

Portability: Good (chains map to subgraph pattern, projection layer adapts).


Final Recommendation

Ship Approach C: Chains Collection + OAA Projection

Total Time: 138 hours (~3.5 weeks)

  • Phase 1 (Round 3 chains): 94 hours (~2.3 weeks)
  • Phase 2 (OAA projection): 44 hours (~1.1 weeks)

Total Code:

  • Backend: ~2,260 lines (1,710 chains + 550 OAA)
  • UI: ~530 lines (from Round 3)
  • Tests: ~760 lines (640 chains + 120 OAA)

Collections Added: 1 (execution_chains)

Entity Types Added: 0

Breaking Changes: 0

Migration Required: No (additive only)

Why Approach C Wins

FactorApproach AApproach BApproach C
Respects Round 3No (replaces chains)No (different model)Yes (builds on chains)
Architectural purityPoor (polluted)Poor (complex)Excellent (clean separation)
OAA compatibilityNativeNativeExport layer
RiskModerateHighLow
Effort76h106h138h
FlexibilityLow (apps fixed)Low (types fixed)High (projection adapts)
RollbackModerateHardEasy
Future-proofNo (locked in)No (locked in)Yes (export layer evolves)

What About "Native" OAA?

The founder's concern was that "OAA has application notion, not only identity." This is true. But SecurityV0 is NOT an OAA system — it's an execution exposure platform that CAN export to OAA format.

Two options:

  1. Force the platform to think in OAA terms: Add application entity type, change resource semantics, model everything as OAA concepts (Approaches A/B)
  2. Keep the platform's native model, project to OAA on export: Chains internally, OAA externally (Approach C)

Approach C wins because:

  • SecurityV0's job is to track execution authority across systems
  • Chains are the right abstraction for this (Round 3 consensus)
  • OAA is one export format among many (could also export to SCIM, Neo4j, custom formats)
  • Polluting the entity type system for one export format creates ongoing maintenance burden

Post-MVP Extensions

Once Approach C ships, future enhancements:

  1. OAA Resources: Project trigger/destination entities as OAA nested resources
  2. OAA Groups: Project owner hierarchy as local_groups
  3. SCIM Export: Add SCIM projection layer (Groups + Users + Entitlements)
  4. Neo4j Export: Add Neo4j cypher projection (Nodes + Edges)
  5. GraphQL API: Add GraphQL layer for flexible queries

All of these are additive projections on top of the chains collection. None require changing entity types.


Appendix: TypeScript Type Definitions

Approach A (Application Entity)

// src/domain/entities/types.ts

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

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

// Application entity doc
export interface ApplicationEntityDoc extends EntityDoc {
entity_type: "application";
properties: {
display_name: string;
status: "active" | "disabled" | "deleted";
application_type: string;
anchor_entity_id: string;
trigger: string;
destination: string;
egress_category?: string;
blast_radius_domains: string[];
ownership_status?: string;
resources?: { name: string; resource_type: string }[];
};
relationships: EntityRelationship[]; // CONTAINS edges
execution_paths: []; // Not applicable
}

Approach B (Resource + DataObject)

// src/domain/entities/types.ts

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

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

// Data object entity doc
export interface DataObjectEntityDoc extends EntityDoc {
entity_type: "data_object";
properties: {
display_name: string;
status: "active" | "disabled" | "deleted";
data_object_type: "table" | "file" | "api" | "bucket" | "endpoint";
business_domain: string;
sensitivity: string;
contains_pii: boolean;
};
relationships: EntityRelationship[];
}

// Resource now means automation artifacts
export interface ResourceEntityDoc extends EntityDoc {
entity_type: "resource";
properties: {
display_name: string;
status: "active" | "disabled" | "deleted";
resource_type: "business_rule" | "flow_designer_flow" | "scheduled_job" | "script_include";
};
relationships: Array<{
type: "TRIGGERS_ON" | "RUNS_AS" | "WRITES_TO" | "READS";
target_id: string;
}>;
}

Approach C (Chains + OAA Projection)

// src/domain/chains/types.ts (from Round 3)

export interface ExecutionChainDoc {
_id: string;
tenant_id: string;
name: string;
anchor_entity_id: string;
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;
}

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;
}

// src/oaa/types.ts (NEW for Round 4)

export interface OAAApplication {
name: string;
application_type: string;
description?: string;
local_users: OAALocalUser[];
local_groups: OAALocalGroup[];
}

export interface OAALocalUser {
name: string;
identities: string[];
groups?: string[];
is_active: boolean;
last_login_at?: string;
}

export interface OAAPermission {
name: string;
permission_type: OAAPermissionType[];
}

export type OAAPermissionType =
| "DataRead" | "DataWrite" | "DataDelete" | "DataCreate"
| "MetadataRead" | "MetadataWrite" | "NonData"
| "OwnershipAssignment" | "ResourceAdmin" | "AccountAdmin";

export interface OAAPayload {
applications: OAAApplication[];
permissions: OAAPermission[];
identity_to_permissions: OAAIdentityToPermission[];
}

End of Round 4 Developer Analysis. Total: ~1,200 lines. Recommendation: Approach C (Chains + OAA Projection) — 138h effort, clean architecture, low risk, high flexibility.