# Feature Specification: Inventory Dependencies Graph

**Feature Branch**: `feat/042-inventory-dependencies-graph`  
**Created**: 2026-01-07  
**Status**: Draft  

## Purpose

Represent and surface dependency relationships between inventory items and foundational Intune objects so admins can understand blast radius and prerequisites.

MVP shows direct inbound/outbound edges only; depth > 1 traversal is out of scope for this iteration.

## Clarifications

### Session 2026-01-10

- Q: Should FR3 be paginated or limit-only for MVP? → A: Limit-only (no pagination).
- Q: Where should unknown/unsupported reference warnings be persisted? → A: On the inventory sync run record (e.g., `InventorySyncRun.error_context.warnings[]`).
- Q: For unknown assignment target shapes, should we create a missing edge or warning-only? → A: Warning-only (no edge created).
- Q: Should `foundation_object` edges always store `metadata.foundation_type`? → A: Yes (required).
- Q: Should the UI show 50 edges total or 50 per direction? → A: 50 per direction (up to 100 total when showing both directions).

### Session 2026-01-10 (042.2)

- Q: Should the Dependencies UI do Entra/Graph lookups to resolve names (e.g., Groups)? → A: No. UI resolution is DB-only.
- Q: How should the UI avoid "Unknown" targets long-term? → A: Render a stable, typed DTO per edge target via a resolver layer (batch queries, no N+1).
**Definitions**:
- **Blast radius**: All resources directly affected by a change to a given item (outbound edges only; no transitive traversal in MVP).
- **Prerequisite**: A hard dependency required for an item to function; missing prerequisites are explicitly surfaced.
- **Inbound edge**: A relationship pointing TO this item (e.g., "Policy A is assigned to Group X" → Group X has inbound edge from Policy A).
- **Outbound edge**: A relationship pointing FROM this item (e.g., "Policy A is scoped by ScopeTag Y" → Policy A has outbound edge to ScopeTag Y).

## User Scenarios & Testing

### Scenario 1: View dependencies for an item
- Given an inventory item
- When the user opens its dependencies view
- Then they can see inbound and outbound relationships (e.g., “uses”, “assigned to”, “scoped by”)

### Scenario 2: Identify missing prerequisites
- Given an item references a prerequisite object not present in inventory
- When the user views dependencies
- Then missing prerequisites are clearly indicated (red badge, "Missing" label, tooltip with last-known displayName if available)

### Scenario 3: Zero dependencies
- Given an item has no inbound or outbound edges
- When the user opens dependencies view
- Then a "No dependencies found" message is shown

### Scenario 4: Filter dependencies by relationship type
- Given multiple relationship types exist
- When the user filters by relationship type (single-select dropdown, default: "All")
- Then only matching edges are shown (empty selection = all edges visible)

### Scenario 6 (042.2): Resolve target names when available
- Given dependency edges to foundation objects (scope tags, assignment filters, groups)
- When the user views dependencies
- Then each edge target is labelled deterministically (no "Unknown")
- And names are resolved only when the target exists in the local DB (no UI Graph lookups)
- And external references (AAD groups) are rendered as external refs without links
### Scenario 5: Only missing prerequisites
- Given an item where all referenced targets are unresolved (no matching inventory or foundation objects)
- When the user opens the dependencies view and selects "Outbound" or "All"
- Then all shown edges are annotated as "Missing" with a red badge and tooltip; filtering still works and zero resolvable targets do not error

## Functional Requirements

- **FR1: Relationship taxonomy**  
  Define a normalized set of relationship types covering inventory→inventory and inventory→foundation edges.  
  Supported types (MVP):
  - `assigned_to` (Policy → AAD Group) *(legacy/general)*
  - `assigned_to_include` (Policy → AAD Group; include assignment)
  - `assigned_to_exclude` (Policy → AAD Group; exclude assignment)
  - `uses_assignment_filter` (Policy → Assignment Filter; metadata `filter_mode=include|exclude`)
  - `scoped_by` (Policy → Scope Tag)
  - `targets` (Update Policy → Device Category, conditional logic)
  - `depends_on` (Generic prerequisite, e.g., Compliance Policy referenced by Conditional Access)
  
  Each type has:
  - `name` (string, e.g., "assigned_to")
  - `display_label` (string, e.g., "Assigned to")
  - `directionality` (enum: `outbound`, `inbound`, `bidirectional`)
  - `description` (brief explanation)

 - **FR2: Dependency edge storage**  
  Store edges in an `inventory_links` table with fields:
  - `id` (PK)
  - `tenant_id` (FK, indexed)
  - `source_type` (string: `inventory_item`, `foundation_object`)
  - `source_id` (UUID or stable ref)
  - `target_type` (string: `inventory_item`, `foundation_object`, `missing`)
  - `target_id` (UUID or stable ref, nullable if missing)
  - `relationship_type` (FK to taxonomy or enum)
  - `metadata` (JSONB, optional: last_known_name, raw_ref, etc.; for `target_type='foundation_object'`, `metadata.foundation_type` is required)
  - `created_at`, `updated_at`
  
  **In-scope foundation object types (MVP)**:
  - AAD Groups (`aad_group`)
  - Scope Tags (`scope_tag`)
  - Device Categories (`device_category`)
  - Assignment Filters (`assignment_filter`)
  
  **Out-of-scope foundation types** (for this iteration): Conditional Access Policies, Compliance Policies as foundation nodes (only as inventory items).

- **FR3: Query inbound/outbound edges**  
  Provide service methods:
  - `getOutboundEdges(item_id, relationship_type?, limit=50)` → returns edges where item is source
  - `getInboundEdges(item_id, relationship_type?, limit=50)` → returns edges where item is target
  
  Both return up to `limit` edges, ordered by `created_at DESC`.

  UI supports filtering by `relationship_type` via a single-select dropdown (default: "All"; empty selection behaves as "All").

- **FR4: Missing prerequisites**  
  When a target reference cannot be resolved:
  - Create edge with `target_type='missing'`, `target_id=null`
  - Store `metadata.last_known_name` and `metadata.raw_ref` if available
  - UI displays "Missing" badge + tooltip
  
  No separate "deleted" or "archived" state in core inventory; missing is purely an edge property.

  Unknown/unsupported reference shapes do not create edges; they are handled via warnings (see NFR2).

- **FR5: Tenant scoping and access control**  
  - All edges filtered by `tenant_id` matching `Tenant::current()`
  - Read access: any authenticated tenant user
  - No cross-tenant queries allowed (enforced at query builder level)

## Non-Functional Requirements

- **NFR1: Idempotency**  
  Dependency extraction must be idempotent:
  - Unique key: `(tenant_id, source_type, source_id, target_type, target_id, relationship_type)`
  - On re-run: upsert (update `updated_at`, replace `metadata` if changed)
  - Orphan edges (source/target no longer in inventory) are NOT auto-deleted; cleanup is manual or scheduled separately

- **NFR2: Graceful unknown-reference handling**  
  If an unknown/unsupported reference shape is encountered:
  - Log warning with severity `info` (not `error`)
  - Do NOT create an edge for unsupported types (including unknown assignment target shapes)
  - Record warning in sync run metadata at `InventorySyncRun.error_context.warnings[]` with shape: `{type: 'unsupported_reference', policy_id, raw_ref, reason}`
  - Sync run continues without failure

## Graph Traversal & Cycles (Out of Scope for MVP)

- Depth > 1 traversal (transitive “blast radius”) is out of scope for this iteration.
- The UI shows only direct inbound/outbound edges.
- Future work may add depth-capped traversal with cycle handling and explicit cycle visualization.

## Success Criteria

- **SC1: Blast radius determination**  
  Admins can determine prerequisites (inbound edges) and blast radius (outbound edges; direct only) for any item in under 2 minutes:
  - Measured from: clicking "View Dependencies" on an item detail page
  - To: able to answer "What would break if I delete this?" and "What does this depend on?"
  - Acceptance: <2s page load, ≤50 edges per direction shown initially (≤100 total when showing both directions), clear visual grouping by relationship type

- **SC2: Deterministic output**  
  For supported relationship types, dependency edges are consistent across re-runs:
  - Given identical inventory state (same items, same Graph API responses)
  - Edge set equality: same `(source, target, relationship_type)` tuples (order-independent)
  - Acceptance: automated test re-runs extraction twice on fixed test data; assert edge sets match (ignoring `updated_at`)

## Security & Privacy

- All data is tenant-scoped; no cross-tenant queries or joins.
- Foundation object visibility:
  - Display name shown only if available from tenant-authorized sources (inventory metadata or prior sync payloads).
  - If not available, show a masked or abbreviated identifier (e.g., first 6 characters of ID) with no external lookup.
- Stored metadata for edges must avoid PII beyond display names surfaced by Graph within the tenant; raw references may be stored but not enriched from outside the tenant scope.

## Traceability

- FR1 (taxonomy) → SC2 (deterministic types), tests: unit taxonomy load/assert
- FR2 (storage) → SC2 (edge equality), tests: feature upsert and equality
- FR3 (queries) → SC1 (answer in <2 min), tests: service returns inbound/outbound within limits
- FR4 (missing) → SC1 (clear prerequisite view), tests: feature missing badge/tooltip
- FR5 (tenant scope) → SC1/SC2 (correct data, deterministic set), tests: tenant isolation

## Out of Scope

- Automatic remediation.
- Cross-tenant dependency graphs.

## Related Specs

- Program: `specs/039-inventory-program/spec.md`
- Core: `specs/040-inventory-core/spec.md`
