# Implementation Plan: Inventory Dependencies Graph **Date**: 2026-01-07 **Spec**: `specs/042-inventory-dependencies-graph/spec.md` ## Summary Add dependency edge model, extraction logic, and UI views to explain relationships between inventory items and prerequisite/foundation objects. ## Dependencies - Inventory items and stable identifiers (Spec 040) - Inventory UI detail pages (Spec 041) or equivalent navigation - Assignments payload (from Spec 006 or equivalent) for `assigned_to` relationships - Scope tags and device categories as foundation objects ## Deliverables - Relationship taxonomy (enum + metadata) - Persisted dependency edges (`inventory_links` table) - Extraction pipeline (integrated into inventory sync) - Query service methods (inbound/outbound) - UI components (Filament section + filtering) - Tests (unit, feature, UI smoke, tenant isolation) ## Risks - Heterogeneous reference shapes across policy types → **Mitigation**: Normalize references early; unsupported shapes → soft warning, skip edge creation - Edge explosion for large tenants → **Mitigation**: Hard limit ≤50 edges per item per direction (enforced at extraction); pagination UI for future --- ## 1. Extraction Pipeline ### Timing & Integration - **When**: Dependencies extracted **during inventory sync run** (not on-demand) - **Hook**: After `InventorySyncService` creates/updates `InventoryItem`, call `DependencyExtractionService` - **Idempotency**: Each sync run reconciles edges for synced items (upsert based on unique key) ### Inputs 1. **InventoryItem** (`raw`, `meta`, `resource_type`, `external_id`) 2. **Assignments payload** (if available): AAD Group IDs 3. **Scope tags** (from item `meta->scopeTags` or raw payload) 4. **Device categories** (from conditional logic in raw payload, if applicable) ### Extraction Logic ```php DependencyExtractionService::extractForItem(InventoryItem $item): array ``` - Parse `$item->raw` and `$item->meta` for known reference shapes - For each reference: - Normalize to `{type, external_id, display_name?}` - Determine `relationship_type` (assigned_to, scoped_by, targets, depends_on) - Resolve target: - If `InventoryItem` exists with matching `external_id` → `target_type='inventory_item'` - If foundation object (AAD Group, Scope Tag, Device Category) → `target_type='foundation_object'` - If unresolvable → `target_type='missing'`, `target_id=null`, store `metadata.last_known_name` - Create/update edge ### Error & Warning Logic - **Unsupported reference shape**: Log warning (`info` severity), record in sync run metadata, skip edge creation - **Low confidence**: If reference parsing heuristic uncertain, create edge with `metadata.confidence='low'` - **Unresolved target**: Create edge with `target_type='missing'` (not an error) - **All targets missing**: Extraction still emits `missing` edges; UI must handle zero resolvable targets without error ### Hard Limits - **Max 50 edges per item per direction** (outbound/inbound) - Enforced at extraction: sort by priority (assigned_to > scoped_by > targets > depends_on), keep top 50, log truncation warning --- ## 2. Storage & Upsert Details ### Schema: `inventory_links` Table ```php Schema::create('inventory_links', function (Blueprint $table) { $table->id(); $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); $table->string('source_type'); // 'inventory_item', 'foundation_object' $table->uuid('source_id'); // InventoryItem.id or foundation stable ID $table->string('target_type'); // 'inventory_item', 'foundation_object', 'missing' $table->uuid('target_id')->nullable(); // null if target_type='missing' $table->string('relationship_type'); // 'assigned_to', 'scoped_by', etc. $table->jsonb('metadata')->nullable(); // {last_known_name, raw_ref, confidence, ...} $table->timestamps(); // Unique key (idempotency) $table->unique([ 'tenant_id', 'source_type', 'source_id', 'target_type', 'target_id', 'relationship_type' ], 'inventory_links_unique'); $table->index(['tenant_id', 'source_type', 'source_id']); $table->index(['tenant_id', 'target_type', 'target_id']); }); ``` ### Upsert Strategy - **On extraction**: `InventoryLink::upsert($edges, uniqueBy: ['tenant_id', 'source_type', 'source_id', 'target_type', 'target_id', 'relationship_type'], update: ['metadata', 'updated_at'])` - **On item deletion**: Edges **NOT** auto-deleted (orphan cleanup is manual or scheduled job) - **On re-run**: Existing edges updated (`updated_at` bumped), new edges created ### Hard Limit Enforcement - Extraction service limits to 50 edges per direction before upserting - Query methods (`getOutboundEdges`, `getInboundEdges`) have default `limit=50` parameter --- ## 3. UI Components (Filament) ### Location - **InventoryItemResource → ViewInventoryItem page** - New section: "Dependencies" (below "Details" section) ### Component Structure ```php // app/Filament/Resources/InventoryItemResource.php (or dedicated Infolist) Section::make('Dependencies') ->schema([ // Filter: Inbound / Outbound / All (default: All) Forms\Components\Select::make('direction') ->options(['all' => 'All', 'inbound' => 'Inbound', 'outbound' => 'Outbound']) ->default('all') ->live(), // Edges table (or custom Blade component) ViewEntry::make('edges') ->view('filament.components.dependency-edges') ->state(fn (InventoryItem $record) => DependencyService::getEdges($record, request('direction', 'all')) ), ]) ``` ### Blade View: `resources/views/filament/components/dependency-edges.blade.php` - **Zero-state**: If `$edges->isEmpty()`: "No dependencies found" - **Edge rendering**: - Group by `relationship_type` (collapsible groups or headings) - Each edge: icon + target name + link (if resolvable) + "Missing" red badge (if target_type='missing') + tooltip (metadata.last_known_name) - **Performance**: Load edges synchronously (≤50 per direction, fast query with indexes) - **Privacy**: If display name unavailable, render masked identifier (e.g., `ID: abcd12…`), no cross-tenant lookups ### Filter Behavior - Single-select dropdown (not multi-select) - Default: "All" (both inbound + outbound shown) - Empty/null selection → treated as "All" --- ## 4. Tests ### Unit Tests - `DependencyExtractionServiceTest.php`: - `test_normalizes_references_deterministically()`: same raw input → same edges (order-independent) - `test_respects_unique_key()`: re-extraction → no duplicate edges - `test_handles_unsupported_references()`: logs warning, skips edge - `InventoryLinkTest.php`: - `test_unique_constraint_enforced()`: duplicate insert → exception or upsert - `test_tenant_scoping()`: edges filtered by tenant_id ### Feature Tests - `DependencyExtractionFeatureTest.php`: - `test_extraction_creates_expected_edges()`: fixture item → assert edges created (assigned_to, scoped_by) - `test_extraction_marks_missing_targets()`: item references non-existent group → edge with target_type='missing' - `test_extraction_respects_50_edge_limit()`: item with 60 refs → only 50 edges created - `test_only_missing_prerequisites_scenario()`: item whose all targets are unresolved → only `missing` edges, UI renders badges - `DependencyQueryServiceTest.php`: - `test_get_outbound_edges_filters_by_tenant()`: cannot see other tenant's edges - `test_get_inbound_edges_returns_correct_direction()` ### UI Smoke Tests - `InventoryItemDependenciesTest.php`: - `test_dependencies_section_renders()`: view page → see "Dependencies" section - `test_filter_works()`: select "Inbound" → only inbound edges shown - `test_zero_state_shown()`: item with no edges → "No dependencies found" - `test_missing_badge_shown()`: edge with target_type='missing' → red badge visible - `test_masks_identifier_when_name_unavailable()`: foundation object without display name → masked ID shown ### Security Tests - `DependencyTenantIsolationTest.php`: - `test_cannot_see_other_tenant_edges()`: tenant A user → cannot query tenant B edges - `test_edges_scoped_by_tenant()`: all query methods enforce tenant_id filter - `test_no_cross_tenant_enrichment()`: service never fetches/display names across tenants --- ## Implementation Sequence 1. **T001**: Define relationship taxonomy (enum + migration for reference data or config) 2. **T002**: Create `inventory_links` migration + model + factory 3. **T003**: Implement `DependencyExtractionService` (extraction logic + normalization) 4. **T004**: Integrate extraction into `InventorySyncService` (post-item-creation hook) 5. **T005**: Implement `DependencyQueryService` (getInbound/Outbound + tenant scoping) 6. **T006**: Add Filament "Dependencies" section to InventoryItem detail page 7. **T007**: Implement filter UI (direction select + live wire) 8. **T008**: Add Blade view for edge rendering (zero-state, missing badge, tooltip) 9. **T009**: Write unit tests (extraction, unique key, tenant scoping) 10. **T010**: Write feature tests (extraction, query, limits) 11. **T011**: Write UI smoke tests (section render, filter, zero-state) 12. **T012**: Run full test suite + Pint