TenantAtlas/specs/042-inventory-dependencies-graph/plan.md
2026-01-10 00:17:15 +01:00

10 KiB

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.

MVP constraints (explicit):

  • Limit-only queries (no pagination/cursors in this iteration).
  • UI shows up to 50 edges per direction (up to 100 total when showing both directions).
  • Unknown/unsupported reference shapes: warning-only (no edge created).
  • Warnings are persisted on the sync run record at InventorySyncRun.error_context.warnings[] (no new tables).

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

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_idtarget_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

MVP clarification: Unknown/unsupported reference shapes are warning-only (no edge created). Warnings are stored at InventorySyncRun.error_context.warnings[].

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::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

// 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(),

    // Filter: Relationship Type (default: All)
    Forms\Components\Select::make('relationship_type')
      ->options(['all' => 'All'] + /* RelationshipType enum options */ [])
      ->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

  • Direction: single-select dropdown; default "All" (both inbound + outbound shown); empty/null treated as "All"
  • Relationship type: single-select dropdown; default "All"; empty/null 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