TenantAtlas/specs/042-inventory-dependencies-graph/plan.md

207 lines
9.2 KiB
Markdown

# 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