diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 1365e1c..90ccf71 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -6,6 +6,7 @@ use App\Models\InventoryItem; use App\Models\Tenant; use App\Services\Inventory\DependencyQueryService; +use App\Support\Enums\RelationshipType; use BackedEnum; use Filament\Actions; use Filament\Infolists\Components\TextEntry; @@ -78,14 +79,21 @@ public static function infolist(Schema $schema): Schema ->view('filament.components.dependency-edges') ->state(function (InventoryItem $record) { $direction = request()->query('direction', 'all'); + $relationshipType = request()->query('relationship_type', 'all'); + $relationshipType = is_string($relationshipType) ? $relationshipType : 'all'; + + $relationshipType = $relationshipType === 'all' + ? null + : RelationshipType::tryFrom($relationshipType)?->value; + $service = app(DependencyQueryService::class); $edges = collect(); if ($direction === 'inbound' || $direction === 'all') { - $edges = $edges->merge($service->getInboundEdges($record)); + $edges = $edges->merge($service->getInboundEdges($record, $relationshipType)); } if ($direction === 'outbound' || $direction === 'all') { - $edges = $edges->merge($service->getOutboundEdges($record)); + $edges = $edges->merge($service->getOutboundEdges($record, $relationshipType)); } return $edges->take(100); // both directions combined diff --git a/app/Support/Enums/RelationshipType.php b/app/Support/Enums/RelationshipType.php index 1bb0a37..2c1f101 100644 --- a/app/Support/Enums/RelationshipType.php +++ b/app/Support/Enums/RelationshipType.php @@ -8,4 +8,28 @@ enum RelationshipType: string case ScopedBy = 'scoped_by'; case Targets = 'targets'; case DependsOn = 'depends_on'; + + public function label(): string + { + return match ($this) { + self::AssignedTo => 'Assigned to', + self::ScopedBy => 'Scoped by', + self::Targets => 'Targets', + self::DependsOn => 'Depends on', + }; + } + + /** + * @return array + */ + public static function options(): array + { + $options = []; + + foreach (self::cases() as $case) { + $options[$case->value] = $case->label(); + } + + return $options; + } } diff --git a/resources/views/filament/components/dependency-edges.blade.php b/resources/views/filament/components/dependency-edges.blade.php index 31ac2b5..cb2240e 100644 --- a/resources/views/filament/components/dependency-edges.blade.php +++ b/resources/views/filament/components/dependency-edges.blade.php @@ -8,6 +8,14 @@ + + + diff --git a/specs/042-inventory-dependencies-graph/checklists/requirements.md b/specs/042-inventory-dependencies-graph/checklists/requirements.md new file mode 100644 index 0000000..b99f72a --- /dev/null +++ b/specs/042-inventory-dependencies-graph/checklists/requirements.md @@ -0,0 +1,37 @@ +# Requirements Checklist — Inventory Dependencies Graph (042) + +## Scope + +- [ ] This checklist applies only to Spec 042 (Inventory Dependencies Graph). +- [ ] MVP scope: show **direct** inbound/outbound edges only (no depth>1 traversal / transitive blast radius). + +## Constitution Gates + +- [ ] Inventory-first: edges derived from last-observed inventory data (no snapshot/backup side effects) +- [ ] Read/write separation: no Intune write paths introduced +- [ ] Single contract path to Graph: Graph access only via GraphClientInterface + contracts (if used) +- [ ] Tenant isolation: all reads/writes tenant-scoped +- [ ] Automation is idempotent & observable: unique key + upsert + run records + stable error codes +- [ ] Data minimization & safe logging: no secrets/tokens; avoid storing raw payloads outside allowed fields +- [ ] No new tables for warnings; warnings persist on InventorySyncRun.error_context.warnings[] + +## Functional Requirements Coverage + +- [ ] FR-001 Relationship taxonomy exists and is testable (labels, directionality, descriptions) +- [ ] FR-002 Dependency edges stored in `inventory_links` with unique key (idempotent upsert) +- [ ] FR-003 Inbound/outbound query services tenant-scoped, limited (MVP: limit-only unless pagination is explicitly implemented) +- [ ] FR-004 Missing prerequisites represented as `target_type='missing'` with safe metadata + UI badge/tooltip +- [ ] FR-005 Relationship-type filtering available in UI (single-select, default “All”) + +## Non-Functional Requirements Coverage + +- [ ] NFR-001 Idempotency: re-running extraction does not create duplicates; updates metadata deterministically +- [ ] NFR-002 Unknown reference shapes handled gracefully: warning recorded in run metadata; does not fail sync; no edge created for unsupported types + +## Tests (Pest) + +- [ ] Extraction determinism + unique key (re-run equality) +- [ ] Missing edges show “Missing” badge and safe tooltip +- [ ] 50-edge limit enforced and truncation behavior is observable (if specified) +- [ ] Tenant isolation for queries and UI +- [ ] UI smoke: relationship-type filter limits visible edges diff --git a/specs/042-inventory-dependencies-graph/plan.md b/specs/042-inventory-dependencies-graph/plan.md index 9793174..b14919d 100644 --- a/specs/042-inventory-dependencies-graph/plan.md +++ b/specs/042-inventory-dependencies-graph/plan.md @@ -7,6 +7,12 @@ ## 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) @@ -63,6 +69,8 @@ ### Error & Warning Logic - **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 @@ -126,6 +134,12 @@ ### Component Structure ->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') @@ -145,9 +159,8 @@ ### Blade View: `resources/views/filament/components/dependency-edges.blade.php` - **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" +- 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" --- diff --git a/specs/042-inventory-dependencies-graph/spec.md b/specs/042-inventory-dependencies-graph/spec.md index 3b2da90..9ba0913 100644 --- a/specs/042-inventory-dependencies-graph/spec.md +++ b/specs/042-inventory-dependencies-graph/spec.md @@ -8,8 +8,20 @@ ## 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). + **Definitions**: -- **Blast radius**: All resources directly or transitively affected by a change to a given item (outbound edges up to depth 2). +- **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). @@ -66,7 +78,7 @@ ## Functional Requirements - `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.) + - `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)**: @@ -81,7 +93,9 @@ ## Functional Requirements - `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 paginated, ordered by `created_at DESC`. + 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: @@ -91,6 +105,8 @@ ## Functional Requirements 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 @@ -107,23 +123,23 @@ ## Non-Functional Requirements - **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 - - Record warning in sync run metadata: `{type: 'unsupported_reference', policy_id, raw_ref, reason}` + - 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 +## Graph Traversal & Cycles (Out of Scope for MVP) -- UI blast radius view is limited to depth ≤ 2 (direct neighbors and their neighbors). -- Traversal uses visited-node tracking to prevent revisiting nodes (cycle break); cycles are implicitly handled by not re-visiting. -- No special UI cycle annotation in MVP; future work may visualize cycles explicitly. +- 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, depth ≤2) for any item in under 2 minutes: + 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, clear visual grouping by relationship type + - 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: diff --git a/specs/042-inventory-dependencies-graph/tasks.md b/specs/042-inventory-dependencies-graph/tasks.md index 241b1cf..e6cb6b7 100644 --- a/specs/042-inventory-dependencies-graph/tasks.md +++ b/specs/042-inventory-dependencies-graph/tasks.md @@ -35,3 +35,14 @@ ## Finalization - [x] T022 Run full test suite (`php artisan test`) - [x] T023 Run Pint (`vendor/bin/pint`) - [x] T024 Update checklist items in `checklists/pr-gate.md` + +## 追加 Tasks (MVP Remediation) +- [ ] T025 Implement relationship-type filter (single-select dropdown, default: all) +- [ ] T026 UI Smoke: relationship-type filter limits edges +- [ ] T027 Create and complete `checklists/requirements.md` (Constitution gate) + +## MVP Constraints (Non-Tasks) +- MVP is limit-only (no pagination/cursors). +- Show up to 50 edges per direction (up to 100 total for "all"). +- Unknown/unsupported shapes are warning-only; persist warnings on run record (`InventorySyncRun.error_context.warnings[]`). +- No new tables for warnings. diff --git a/tests/Feature/InventoryItemDependenciesTest.php b/tests/Feature/InventoryItemDependenciesTest.php index 22fc778..8af983e 100644 --- a/tests/Feature/InventoryItemDependenciesTest.php +++ b/tests/Feature/InventoryItemDependenciesTest.php @@ -69,3 +69,43 @@ $urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=inbound'; $this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found'); }); + +it('relationship filter limits edges by type', function () { + [$user, $tenant] = createUserWithTenant(); + $this->actingAs($user); + + /** @var InventoryItem $item */ + $item = InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'external_id' => (string) Str::uuid(), + ]); + + // Two outbound edges with different relationship types. + InventoryLink::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'source_type' => 'inventory_item', + 'source_id' => $item->external_id, + 'target_type' => 'missing', + 'target_id' => null, + 'relationship_type' => 'assigned_to', + 'metadata' => ['last_known_name' => 'Assigned Target'], + ]); + + InventoryLink::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'source_type' => 'inventory_item', + 'source_id' => $item->external_id, + 'target_type' => 'missing', + 'target_id' => null, + 'relationship_type' => 'scoped_by', + 'metadata' => ['last_known_name' => 'Scoped Target'], + ]); + + $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant) + .'?direction=outbound&relationship_type=scoped_by'; + + $this->get($url) + ->assertOk() + ->assertSee('Scoped Target') + ->assertDontSee('Assigned Target'); +});