feat/042-inventory-dependencies-graph (#49)

Ordering + limit-only Test für created_at DESC in DependencyExtractionFeatureTest.php
UI Test für masked Identifier (ID: 123456…) + Guest-Access blocked in InventoryItemDependenciesTest.php
Quickstart ergänzt um manuellen <2s Check in quickstart.md
pr-gate Checkbox-Format normalisiert (kein leading space) in pr-gate.md

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #49
This commit is contained in:
ahmido 2026-01-10 00:20:14 +00:00
parent cf5b0027e3
commit 361e301f67
27 changed files with 1921 additions and 37 deletions

View File

@ -6,6 +6,8 @@ ## Active Technologies
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations) - PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations) - PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
- PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp) - PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp)
- PHP 8.4.x + Laravel 12, Filament v4, Livewire v3 (feat/042-inventory-dependencies-graph)
- PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -25,10 +27,10 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- feat/042-inventory-dependencies-graph: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
- feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 - feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
- feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 - feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
- feat/005-bulk-operations: Added PHP 8.4.15
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -5,6 +5,8 @@
use App\Filament\Resources\InventoryItemResource\Pages; use App\Filament\Resources\InventoryItemResource\Pages;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Inventory\DependencyQueryService;
use App\Support\Enums\RelationshipType;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
@ -70,6 +72,36 @@ public static function infolist(Schema $schema): Schema
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make('Dependencies')
->schema([
ViewEntry::make('dependencies')
->label('')
->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, $relationshipType));
}
if ($direction === 'outbound' || $direction === 'all') {
$edges = $edges->merge($service->getOutboundEdges($record, $relationshipType));
}
return $edges->take(100); // both directions combined
})
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Metadata (Safe Subset)') Section::make('Metadata (Safe Subset)')
->schema([ ->schema([
ViewEntry::make('meta_jsonb') ViewEntry::make('meta_jsonb')

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class InventoryLink extends Model
{
use HasFactory;
protected $table = 'inventory_links';
protected $guarded = [];
protected function casts(): array
{
return [
'metadata' => 'array',
];
}
}

View File

@ -0,0 +1,147 @@
<?php
namespace App\Services\Inventory;
use App\Models\InventoryItem;
use App\Models\InventoryLink;
use App\Support\Enums\RelationshipType;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class DependencyExtractionService
{
/**
* Extracts dependencies for a given inventory item using the raw policy payload if available.
* Idempotent via unique key on inventory_links. Enforces a max of 50 outbound edges per item.
*
* @param array<string, mixed> $policyData
*/
public function extractForPolicyData(InventoryItem $item, array $policyData): array
{
$warnings = [];
$edges = collect();
$edges = $edges->merge($this->extractAssignedTo($item, $policyData, $warnings));
$edges = $edges->merge($this->extractScopedBy($item, $policyData));
// Enforce max 50 outbound edges by priority: assigned_to > scoped_by > others
$priorities = [
RelationshipType::AssignedTo->value => 1,
RelationshipType::ScopedBy->value => 2,
RelationshipType::Targets->value => 3,
RelationshipType::DependsOn->value => 4,
];
/** @var Collection<int, array{tenant_id:int,source_type:string,source_id:string,target_type:string,target_id:?string,relationship_type:string,metadata:array}> $sorted */
$sorted = $edges->sortBy(fn ($e) => $priorities[$e['relationship_type']] ?? 99)->values();
$limited = $sorted->take(50);
$now = now();
$payload = $limited->map(function (array $e) use ($now) {
$metadata = $e['metadata'] ?? null;
if (is_array($metadata)) {
// Ensure portability across SQLite/Postgres when using upsert via query builder
$e['metadata'] = json_encode($metadata);
}
return array_merge($e, [
'created_at' => $now,
'updated_at' => $now,
]);
})->all();
if (! empty($payload)) {
InventoryLink::query()->upsert(
$payload,
['tenant_id', 'source_type', 'source_id', 'target_type', 'target_id', 'relationship_type'],
['metadata', 'updated_at']
);
}
return $warnings;
}
/**
* @param array<string, mixed> $policyData
* @return Collection<int, array<string, mixed>>
*/
private function extractAssignedTo(InventoryItem $item, array $policyData, array &$warnings): Collection
{
$assignments = Arr::get($policyData, 'assignments');
if (! is_array($assignments)) {
return collect();
}
$edges = [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
// Known shapes: ['target' => ['groupId' => '...']] or direct ['groupId' => '...']
$groupId = Arr::get($assignment, 'target.groupId') ?? Arr::get($assignment, 'groupId');
if (is_string($groupId) && $groupId !== '') {
$edges[] = [
'tenant_id' => (int) $item->tenant_id,
'source_type' => 'inventory_item',
'source_id' => (string) $item->external_id,
'target_type' => 'foundation_object',
'target_id' => $groupId,
'relationship_type' => RelationshipType::AssignedTo->value,
'metadata' => [
'last_known_name' => null,
'foundation_type' => 'aad_group',
],
];
} else {
$warning = [
'type' => 'unsupported_reference',
'policy_id' => (string) ($policyData['id'] ?? $item->external_id),
'raw_ref' => $assignment,
'reason' => 'unsupported_assignment_target_shape',
];
$warnings[] = $warning;
Log::info('Unsupported reference shape encountered', $warning);
}
}
return collect($edges);
}
/**
* @param array<string, mixed> $policyData
* @return Collection<int, array<string, mixed>>
*/
private function extractScopedBy(InventoryItem $item, array $policyData): Collection
{
$scopeTags = Arr::get($policyData, 'roleScopeTagIds');
if (! is_array($scopeTags)) {
return collect();
}
$edges = [];
foreach ($scopeTags as $tagId) {
if (is_string($tagId) && $tagId !== '') {
$edges[] = [
'tenant_id' => (int) $item->tenant_id,
'source_type' => 'inventory_item',
'source_id' => (string) $item->external_id,
'target_type' => 'foundation_object',
'target_id' => $tagId,
'relationship_type' => RelationshipType::ScopedBy->value,
'metadata' => [
'last_known_name' => null,
'foundation_type' => 'scope_tag',
],
];
}
}
return collect($edges);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Services\Inventory;
use App\Models\InventoryItem;
use App\Models\InventoryLink;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
class DependencyQueryService
{
public function getOutboundEdges(InventoryItem $item, ?string $relationshipType = null, int $limit = 50): EloquentCollection
{
$query = InventoryLink::query()
->where('tenant_id', $item->tenant_id)
->where('source_type', 'inventory_item')
->where('source_id', $item->external_id)
->orderByDesc('created_at');
if ($relationshipType !== null) {
$query->where('relationship_type', $relationshipType);
}
return $query->limit($limit)->get();
}
public function getInboundEdges(InventoryItem $item, ?string $relationshipType = null, int $limit = 50): EloquentCollection
{
$query = InventoryLink::query()
->where('tenant_id', $item->tenant_id)
->where('target_type', 'inventory_item')
->where('target_id', $item->external_id)
->orderByDesc('created_at');
if ($relationshipType !== null) {
$query->where('relationship_type', $relationshipType);
}
return $query->limit($limit)->get();
}
}

View File

@ -239,6 +239,7 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
$errors = 0; $errors = 0;
$errorCodes = []; $errorCodes = [];
$hadErrors = false; $hadErrors = false;
$warnings = [];
try { try {
$typesConfig = $this->supportedTypeConfigByType(); $typesConfig = $this->supportedTypeConfigByType();
@ -307,7 +308,7 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
'warnings' => [], 'warnings' => [],
]); ]);
InventoryItem::query()->updateOrCreate( $item = InventoryItem::query()->updateOrCreate(
[ [
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'policy_type' => $policyType, 'policy_type' => $policyType,
@ -324,6 +325,16 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
); );
$upserted++; $upserted++;
// Extract dependencies if requested in selection
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true);
if ($includeDeps) {
$warnings = array_merge(
$warnings,
app(\App\Services\Inventory\DependencyExtractionService::class)
->extractForPolicyData($item, $policyData)
);
}
} }
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null); $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null);
@ -335,7 +346,9 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
'status' => $status, 'status' => $status,
'had_errors' => $hadErrors, 'had_errors' => $hadErrors,
'error_codes' => array_values(array_unique($errorCodes)), 'error_codes' => array_values(array_unique($errorCodes)),
'error_context' => null, 'error_context' => [
'warnings' => array_values($warnings),
],
'items_observed_count' => $observed, 'items_observed_count' => $observed,
'items_upserted_count' => $upserted, 'items_upserted_count' => $upserted,
'errors_count' => $errors, 'errors_count' => $errors,
@ -344,11 +357,14 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
return $run->refresh(); return $run->refresh();
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$errorContext = $this->safeErrorContext($throwable);
$errorContext['warnings'] = array_values($warnings);
$run->update([ $run->update([
'status' => InventorySyncRun::STATUS_FAILED, 'status' => InventorySyncRun::STATUS_FAILED,
'had_errors' => true, 'had_errors' => true,
'error_codes' => ['unexpected_exception'], 'error_codes' => ['unexpected_exception'],
'error_context' => $this->safeErrorContext($throwable), 'error_context' => $errorContext,
'items_observed_count' => $observed, 'items_observed_count' => $observed,
'items_upserted_count' => $upserted, 'items_upserted_count' => $upserted,
'errors_count' => $errors + 1, 'errors_count' => $errors + 1,

View File

@ -0,0 +1,35 @@
<?php
namespace App\Support\Enums;
enum RelationshipType: string
{
case AssignedTo = 'assigned_to';
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<string, string>
*/
public static function options(): array
{
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Models\InventoryLink;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<InventoryLink>
*/
class InventoryLinkFactory extends Factory
{
protected $model = InventoryLink::class;
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'source_type' => 'inventory_item',
'source_id' => $this->faker->uuid(),
'target_type' => 'foundation_object',
'target_id' => $this->faker->uuid(),
'relationship_type' => 'assigned_to',
'metadata' => [
'last_known_name' => $this->faker->words(3, true),
],
];
}
}

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('inventory_links', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('source_type');
$table->uuid('source_id');
$table->string('target_type');
$table->uuid('target_id')->nullable();
$table->string('relationship_type');
$table->jsonb('metadata')->nullable();
$table->timestamps();
$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']);
});
}
public function down(): void
{
Schema::dropIfExists('inventory_links');
}
};

View File

@ -0,0 +1,66 @@
@php /** @var callable $getState */ @endphp
<div class="space-y-4">
<form method="GET" class="flex items-center gap-2">
<label for="direction" class="text-sm text-gray-600">Direction</label>
<select id="direction" name="direction" class="fi-input fi-select">
<option value="all" {{ request('direction', 'all') === 'all' ? 'selected' : '' }}>All</option>
<option value="inbound" {{ request('direction') === 'inbound' ? 'selected' : '' }}>Inbound</option>
<option value="outbound" {{ request('direction') === 'outbound' ? 'selected' : '' }}>Outbound</option>
</select>
<label for="relationship_type" class="text-sm text-gray-600">Relationship</label>
<select id="relationship_type" name="relationship_type" class="fi-input fi-select">
<option value="all" {{ request('relationship_type', 'all') === 'all' ? 'selected' : '' }}>All</option>
@foreach (\App\Support\Enums\RelationshipType::options() as $value => $label)
<option value="{{ $value }}" {{ request('relationship_type') === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<button type="submit" class="fi-btn">Apply</button>
</form>
@php
$raw = $getState();
$edges = $raw instanceof \Illuminate\Support\Collection ? $raw : collect($raw);
@endphp
@if ($edges->isEmpty())
<div class="text-sm text-gray-500">No dependencies found</div>
@else
<div class="divide-y">
@foreach ($edges->groupBy('relationship_type') as $type => $group)
<div class="py-2">
<div class="text-xs uppercase tracking-wide text-gray-600 mb-2">{{ str_replace('_', ' ', $type) }}</div>
<ul class="space-y-1">
@foreach ($group as $edge)
@php
$isMissing = ($edge['target_type'] ?? null) === 'missing';
$name = $edge['metadata']['last_known_name'] ?? null;
$targetId = $edge['target_id'] ?? null;
$display = $name ?: ($targetId ? ("ID: ".substr($targetId,0,6)."") : 'Unknown');
$missingTitle = 'Missing target';
if (is_string($name) && $name !== '') {
$missingTitle .= ". Last known: {$name}";
}
$rawRef = $edge['metadata']['raw_ref'] ?? null;
if ($rawRef !== null) {
$encodedRef = json_encode($rawRef);
if (is_string($encodedRef) && $encodedRef !== '') {
$missingTitle .= '. Ref: '.\Illuminate\Support\Str::limit($encodedRef, 200);
}
}
@endphp
<li class="flex items-center gap-2 text-sm">
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $display }}</span>
@if ($isMissing)
<span class="fi-badge fi-badge-danger" title="{{ $missingTitle }}">Missing</span>
@endif
</li>
@endforeach
</ul>
</div>
@endforeach
</div>
@endif
</div>

View File

@ -0,0 +1,78 @@
# Dependencies Checklist: Inventory Dependencies Graph
**Purpose**: Validate that Spec 042s dependency-graph requirements are complete, unambiguous, and testable ("unit tests for English").
**Created**: 2026-01-07
**Feature**: `specs/042-inventory-dependencies-graph/spec.md`
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
## Requirement Completeness
- [x] CHK001 Are relationship types fully enumerated with definitions and at least one example per type? [Completeness, Spec §FR1]
- [x] CHK002 Are the minimum required fields for a dependency edge explicitly specified (e.g., source, target, type, directionality, timestamps, provenance)? [Gap, Spec §FR2]
- [x] CHK003 Are the categories of “other objects” (non-inventory foundations) explicitly enumerated and bounded (what is in-scope vs out-of-scope)? [Completeness, Spec §FR2]
- [x] CHK004 Are the identifiers/keys used to reference inventory items and foundational objects clearly specified (stable IDs vs display names)? [Gap, Plan §Dependencies]
- [x] CHK005 Are inbound and outbound edge queries both explicitly required for all supported relationship types (or are exceptions called out)? [Completeness, Spec §FR3]
- [x] CHK006 Are “missing prerequisites” criteria specified (what counts as missing, and how missing is detected)? [Completeness, Spec §Scenario 2, FR4]
- [x] CHK007 Are access control requirements defined beyond “access-controlled” (roles/permissions, tenant admin vs read-only, etc.)? [Gap, Spec §FR5]
## Requirement Clarity
- [x] CHK008 Is the meaning of “inbound” vs “outbound” relationships defined unambiguously (especially for asymmetric relations like “assigned to”)? [Clarity, Spec §Scenario 1, FR3]
- [x] CHK009 Are relationship labels (“uses”, “assigned to”, “scoped by”) defined as a taxonomy with consistent naming, directionality, and semantics? [Clarity, Spec §Scenario 1, FR1]
- [x] CHK010 Is “blast radius” translated into concrete, observable dependency-graph concepts (e.g., outbound edges only, both directions, depth)? [Ambiguity, Spec §Purpose]
- [x] CHK011 Is “prerequisite” defined precisely (e.g., hard prerequisite vs informational dependency; required vs optional)? [Ambiguity, Spec §Purpose, Scenario 2]
- [x] CHK012 Is the filter behavior for relationship types specified (single vs multi-select, default selection, empty selection meaning)? [Gap, Spec §Scenario 3]
- [x] CHK013 Are “safe warning” requirements specified with a format/fields and where they surface (sync logs, UI banner, audit log)? [Clarity, Spec §NFR2]
## Requirement Consistency
- [x] CHK014 Do “missing prerequisites” requirements align with “no separate deleted state in core inventory” without introducing contradictory states or terminology? [Consistency, Spec §FR4]
- [x] CHK015 Are the relationship examples in scenarios consistent with the relationship taxonomy required by FR1 (no scenario-only types)? [Consistency, Spec §Scenario 1, FR1]
- [x] CHK016 Do idempotency requirements (NFR1) align with determinism requirements (SC2) without ambiguity about ordering, deduplication, or normalization? [Consistency, Spec §NFR1, SC2]
- [x] CHK017 Are tenant-scoping requirements consistent across storage, querying, and UI exposure (no implied cross-tenant joins)? [Consistency, Spec §FR5, Out of Scope]
## Acceptance Criteria Quality
- [x] CHK018 Is SC1 (“under 2 minutes”) made measurable with a defined starting point, scope (single item, depth), and success signal (what the admin must be able to conclude)? [Measurability, Spec §SC1]
- [x] CHK019 Is SC2 (“deterministic output”) made measurable by defining what equivalence means (edge set equality, stable IDs, normalized relationship types)? [Measurability, Spec §SC2]
- [x] CHK020 Are acceptance criteria mapped to each Functional Requirement (FR1FR5) so each requirement has an objective pass/fail definition? [Gap, Spec §Functional Requirements]
## Scenario Coverage
- [x] CHK021 Do scenarios cover both inbound and outbound viewing requirements explicitly (or is one direction implicitly assumed)? [Coverage, Spec §Scenario 1, FR3]
- [x] CHK022 Are scenarios defined for “no dependencies” (zero edges) and how that is communicated to the user? [Gap, Spec §User Scenarios & Testing]
- [x] CHK023 Are scenarios defined for “only missing prerequisites” (all targets missing) and how that impacts filtering or display? [Gap, Spec §Scenario 2]
- [x] CHK024 Are scenarios defined for mixed object types (inventory item → foundation object, foundation → inventory item) if both are supported? [Gap, Spec §FR2]
## Edge Case Coverage
- [x] CHK025 Are requirements specified for unknown/unsupported references beyond “record a safe warning” (e.g., whether an edge is skipped, recorded as unknown node, or preserved as raw ref)? [Coverage, Spec §NFR2]
- [x] CHK026 Are requirements defined for duplicate references within a single item (e.g., same target referenced multiple times) and expected edge deduplication rules? [Gap, Spec §NFR1, SC2]
- [x] CHK027 Are cyclic dependencies explicitly addressed (allow, detect, show, and/or bound traversal depth)? [Gap, Spec §Purpose]
- [x] CHK028 Are requirements defined for very large graphs (pagination, depth limits, maximum edges returned) given the stated “edge explosion” risk? [Gap, Plan §Risks]
## Non-Functional Requirements
- [x] CHK029 Does NFR1 define idempotency scope and mechanism expectations (e.g., uniqueness keys, replace-all vs upsert, run-scoped vs global)? [Clarity, Spec §NFR1]
- [x] CHK030 Does NFR2 define what constitutes “must not fail an inventory sync run” (soft-fail boundary, error severity classes, retries)? [Clarity, Spec §NFR2]
- [x] CHK031 Are performance requirements (latency, memory, query limits) specified for dependency extraction and for dependency viewing queries? [Gap, Plan §Risks]
- [x] CHK032 Are security/privacy requirements specified for what dependency data may expose (e.g., names/IDs of foundation objects) and who can see it? [Gap, Spec §FR5]
## Dependencies & Assumptions
- [x] CHK033 Are dependencies on Spec 040 identifiers and Spec 041 UI explicitly stated as hard requirements vs optional integration points? [Clarity, Plan §Dependencies]
- [x] CHK034 Are assumptions documented about which Intune object types contain references and the reference shapes expected (“heterogeneous reference shapes” risk)? [Assumption, Plan §Risks]
- [x] CHK035 Are assumptions documented about data freshness (when edges are extracted relative to inventory sync, and how stale edges are handled)? [Gap, Spec §NFR1]
## Ambiguities & Conflicts
- [x] CHK036 Is “foundation object not present in inventory” terminology consistent with “not requiring a deleted state” (missing vs absent vs excluded)? [Ambiguity, Spec §FR4]
- [x] CHK037 Is it explicitly defined whether cross-item dependencies are limited to within a policy type or across all inventory types? [Gap, Spec §Purpose, FR2]
- [x] CHK038 Is it clear whether relationship filtering applies only to edge types, or also to node/object types (inventory vs foundations)? [Gap, Spec §Scenario 3]
## Notes
- Check items off as completed: `[x]`
- Add findings inline under the relevant checklist item
- Each `/speckit.checklist` run creates a new checklist file

View File

@ -0,0 +1,76 @@
# PR Gate Checklist: Inventory Dependencies Graph
**Purpose**: PR-Review-Checklist zur Bewertung der Anforderungsqualität für Spec 042 (50% Daten/Pipeline, 50% Darstellung). Fokus: messbar, deterministisch reviewbar, scope-stabil durch „Enumerate now“.
**Created**: 2026-01-07
**Feature**: `specs/042-inventory-dependencies-graph/spec.md`
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
## Requirement Completeness
- [x] CHK001 Ist die Relationship-Taxonomie vollständig enumeriert (Set an Typen) und pro Typ mit Definition + Richtung + Beispiel beschrieben? [Completeness, Spec §FR1]
- [x] CHK002 Sind alle in-scope „foundational Intune objects“ explizit aufgelistet (Enumerate now) inkl. klarer Exclusions, um Scope zu binden? [Gap, Spec §FR2, Plan §Risks]
- [x] CHK003 Sind Mindestfelder einer Dependency-Edge als Requirement beschrieben (source, target, relationship_type, directionality, provenance/derived_from, timestamps/created_at, optional metadata)? [Gap, Spec §FR2]
- [x] CHK004 Ist klar spezifiziert, wie „inventory item“ eindeutig referenziert wird (stable identifier), und wie foundation objects referenziert werden (ID/uri/type tuple)? [Gap, Plan §Dependencies]
- [x] CHK005 Sind inbound UND outbound Abfragen als explizite Requirements beschrieben (inkl. erwarteter Sortierung/Limitierung oder bewusst „unspecified“)? [Completeness, Spec §FR3]
- [x] CHK006 Sind Missing-Prerequisites als eigene Requirement-Klasse beschrieben (Erkennung, Darstellung, und welche Daten minimal gezeigt werden dürfen)? [Completeness, Spec §FR4, Scenario 2]
- [x] CHK007 Ist Relationship-Type-Filtering vollständig spezifiziert (Scope, Default, Mehrfachauswahl ja/nein, Verhalten bei „none selected“)? [Completeness, Spec §Scenario 3]
- [x] CHK008 Sind Tenant-Scoping und Access Control Requirements konkretisiert (welche Rollen/Capabilities; Read vs View Details; ggf. audit expectations)? [Gap, Spec §FR5]
## Requirement Clarity
- [x] CHK009 Ist „inbound“ vs „outbound“ formal definiert pro Relationship-Type (nicht nur im Textbeispiel), um Interpretationsspielraum zu vermeiden? [Clarity, Spec §Scenario 1, FR3]
- [x] CHK010 Sind Relationship-Namen und Semantik konsistent (z.B. „assigned to“ ist eindeutig Richtung A→B) und nicht synonym/überlappend? [Clarity, Spec §FR1]
- [x] CHK011 Ist „blast radius“ in messbare Graph-Konzepte übersetzt (z.B. „outbound edges bis Tiefe N“, „both directions“, oder explizit „only direct neighbors“)? [Ambiguity, Spec §Purpose]
- [x] CHK012 Ist „prerequisite“ eindeutig definiert (hard vs informational, required vs optional) und ist diese Definition in Missing-Prerequisites konsistent wiederverwendet? [Ambiguity, Spec §Purpose, Scenario 2]
- [x] CHK013 Ist „safe warning“ (NFR2) klar operationalisiert: Inhalt/Felder, Severity, Persistenz, und wo es sichtbar wird (Run-Log vs UI vs Audit)? [Clarity, Spec §NFR2]
## Requirement Consistency
- [x] CHK014 Sind Scenario-Beispiele („uses“, „assigned to“, „scoped by“) vollständig Teil der FR1-Taxonomie (keine scenario-only Typen)? [Consistency, Spec §Scenario 1, FR1]
- [x] CHK015 Ist FR4 („missing prerequisites“) konsistent mit „ohne deleted state in core inventory“ beschrieben (kein implizites soft-delete/archived eingeführt)? [Consistency, Spec §FR4]
- [x] CHK016 Sind NFR1 (idempotent) und SC2 (deterministic output) konfliktfrei und eindeutig, was Gleichheit bedeutet (Edge-Set, Normalisierung, Sortierung)? [Consistency, Spec §NFR1, SC2]
- [x] CHK017 Ist der Tenant-Scope konsistent in Storage, Query und UI (keine impliziten cross-tenant Graphs; Out-of-scope ist explizit)? [Consistency, Spec §FR5, Out of Scope]
## Acceptance Criteria Quality
- [x] CHK018 Ist SC1 („unter 2 Minuten“) so definiert, dass Reviewer objektiv prüfen können, was „determine prerequisites and blast radius“ konkret bedeutet (Tiefe, Umfang, Informationsumfang)? [Measurability, Spec §SC1]
- [x] CHK019 Ist SC2 so messbar formuliert, dass deterministische Output-Gleichheit ohne Interpretationsspielraum prüfbar ist (z.B. canonical ordering + uniqueness rules)? [Measurability, Spec §SC2]
- [x] CHK020 Gibt es eine klare Traceability zwischen FR1FR5 und Success Criteria (jedes FR hat mindestens ein objektives Akzeptanzkriterium oder ist bewusst als „non-testable“ markiert)? [Gap, Spec §Functional Requirements, Success Criteria]
## Scenario Coverage
- [x] CHK021 Deckt die Spec explizit den Zero-State ab („no edges“ / „no deps“), inkl. erwarteter UI-Messaging-Requirement? [Gap, Spec §User Scenarios & Testing]
- [x] CHK022 Deckt die Spec explizit Mixed-Targets ab (Inventory→Foundation, Inventory→Inventory) und ob Foundation→Inventory als inbound dargestellt werden soll? [Gap, Spec §FR2, FR3]
- [x] CHK023 Gibt es definierte Requirements für „only missing prerequisites“ (alle Targets missing) und wie Filter/Display damit umgehen? [Gap, Spec §Scenario 2]
## Edge Case Coverage
- [x] CHK024 Sind Unknown/Unsupported References (NFR2) vollständig als Requirements abgedeckt: ob Edge erzeugt wird, ob Node „unknown“ erlaubt ist, ob raw reference gespeichert wird? [Coverage, Spec §NFR2]
- [x] CHK025 Sind Duplicate References innerhalb eines Items geregelt (Dedup-Key, Merge Rules), um NFR1/SC2 deterministisch einzuhalten? [Gap, Spec §NFR1, SC2]
- [x] CHK026 Sind zyklische Dependencies als Requirement adressiert (Erkennung/Handling/Traversal-Limits), damit „blast radius“ nicht unendlich wird? [Gap, Spec §Purpose, Plan §Risks]
- [x] CHK027 Sind Grenzen für Edge-Explosion als Requirements spezifiziert (Limits, pagination, depth caps, server-side constraints), nicht nur als Risiko erwähnt? [Gap, Plan §Risks]
## Non-Functional Requirements
- [x] CHK028 Ist Idempotenz (NFR1) präzisiert: Scope (per-run vs global), Unique Keys, Upsert vs Replace-All, und ob Deletions/Orphan-Edges geregelt sind? [Clarity, Spec §NFR1]
- [x] CHK029 Ist „must not fail an inventory sync run“ präzisiert: welche Fehler sind soft-fail, welche sind hard-fail, und wie wird das für Reviewer nachvollziehbar? [Clarity, Spec §NFR2]
- [x] CHK030 Sind Performance-/Skalierungsanforderungen spezifiziert (UI Query Latency, max edges returned, extraction time budget) statt nur „Risiko“? [Gap, Plan §Risks]
- [x] CHK031 Sind Security/Privacy-Anforderungen spezifiziert, welche foundation-object Daten sichtbar sein dürfen (IDs vs Names) und ob das tenant- & permission-scoped ist? [Gap, Spec §FR5]
## Dependencies & Assumptions
- [x] CHK032 Sind Abhängigkeiten zu Spec 040 (stable identifiers) und Spec 041 (UI navigation/detail pages) als Requirements eindeutig dokumentiert (hard requirement vs optional)? [Clarity, Plan §Dependencies]
- [x] CHK033 Sind Annahmen über heterogene Reference-Shapes explizit dokumentiert und ist klar, wie neue Shapes in Scope aufgenommen werden (Change control / taxonomy update)? [Assumption, Plan §Risks]
- [x] CHK034 Ist explizit dokumentiert, wann Edges extrahiert werden (im sync run vs post-processing), und wie Staleness/Refresh geregelt ist? [Gap, Spec §NFR1]
## Ambiguities & Conflicts
- [x] CHK035 Sind Begriffe „missing“, „not present“, „excluded“, „out-of-scope“ sauber definiert und konsistent verwendet (kein Vermischen von Datenzustand und Scope)? [Ambiguity, Spec §FR4, Out of Scope]
- [x] CHK036 Ist klar, ob Filtering nur Relationship-Types betrifft oder auch Node-Types (Inventory vs Foundation) und ob beides kombinierbar sein soll? [Gap, Spec §Scenario 3]
## Notes
- Check items off as completed: `[x]`
- Findings als kurze Stichpunkte direkt unter dem jeweiligen Item ergänzen
- Jede `/speckit.checklist` Ausführung erzeugt eine neue Datei (kein Overwrite)

View File

@ -0,0 +1,37 @@
# Requirements Checklist — Inventory Dependencies Graph (042)
## Scope
- [x] This checklist applies only to Spec 042 (Inventory Dependencies Graph).
- [x] MVP scope: show **direct** inbound/outbound edges only (no depth>1 traversal / transitive blast radius).
## Constitution Gates
- [x] Inventory-first: edges derived from last-observed inventory data (no snapshot/backup side effects)
- [x] Read/write separation: no Intune write paths introduced
- [x] Single contract path to Graph: Graph access only via GraphClientInterface + contracts (if used)
- [x] Tenant isolation: all reads/writes tenant-scoped
- [x] Automation is idempotent & observable: unique key + upsert + run records + stable error codes
- [x] Data minimization & safe logging: no secrets/tokens; avoid storing raw payloads outside allowed fields
- [x] No new tables for warnings; warnings persist on InventorySyncRun.error_context.warnings[]
## Functional Requirements Coverage
- [x] FR-001 Relationship taxonomy exists and is testable (labels, directionality, descriptions)
- [x] FR-002 Dependency edges stored in `inventory_links` with unique key (idempotent upsert)
- [x] FR-003 Inbound/outbound query services tenant-scoped, limited (MVP: limit-only unless pagination is explicitly implemented)
- [x] FR-004 Missing prerequisites represented as `target_type='missing'` with safe metadata + UI badge/tooltip
- [x] FR-005 Relationship-type filtering available in UI (single-select, default “All”)
## Non-Functional Requirements Coverage
- [x] NFR-001 Idempotency: re-running extraction does not create duplicates; updates metadata deterministically
- [x] NFR-002 Unknown reference shapes handled gracefully: warning recorded in run metadata; does not fail sync; no edge created for unsupported types
## Tests (Pest)
- [x] Extraction determinism + unique key (re-run equality)
- [x] Missing edges show “Missing” badge and safe tooltip
- [x] 50-edge limit enforced and truncation behavior is observable (if specified)
- [x] Tenant isolation for queries and UI
- [x] UI smoke: relationship-type filter limits visible edges

View File

@ -0,0 +1,5 @@
# Contracts — Inventory Dependencies Graph (042)
This feature does not introduce a new public HTTP API in MVP (Filament page is server-rendered).
The contracts in this folder describe the internal data shape passed from query/services to the UI rendering layer.

View File

@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "tenantpilot://contracts/042/dependency-edge.schema.json",
"title": "DependencyEdge",
"type": "object",
"additionalProperties": true,
"required": [
"tenant_id",
"source_type",
"source_id",
"target_type",
"relationship_type"
],
"properties": {
"tenant_id": { "type": "integer" },
"source_type": { "type": "string", "enum": ["inventory_item", "foundation_object"] },
"source_id": { "type": "string" },
"target_type": { "type": "string", "enum": ["inventory_item", "foundation_object", "missing"] },
"target_id": { "type": ["string", "null"] },
"relationship_type": { "type": "string", "enum": ["assigned_to", "scoped_by", "targets", "depends_on"] },
"metadata": {
"type": ["object", "null"],
"additionalProperties": true,
"properties": {
"last_known_name": { "type": ["string", "null"] },
"raw_ref": {},
"foundation_type": { "type": "string", "enum": ["aad_group", "scope_tag", "device_category"] }
}
},
"created_at": { "type": ["string", "null"], "description": "ISO-8601 timestamp" },
"updated_at": { "type": ["string", "null"], "description": "ISO-8601 timestamp" }
}
}

View File

@ -0,0 +1,72 @@
# Data Model — Inventory Dependencies Graph (042)
## Entities
### InventoryItem
Existing entity (Spec 040).
Key fields used by this feature:
- `tenant_id` (FK)
- `external_id` (string; stable identifier used as edge endpoint)
- `policy_type` (string)
- `display_name` (nullable string)
- `meta_jsonb` (array/jsonb; safe subset)
### InventorySyncRun
Existing entity used for observability of sync operations.
Key fields used by this feature:
- `tenant_id`
- `selection_hash`
- `selection_payload` (array)
- `status` (running/success/partial/failed/skipped)
- `had_errors` (bool)
- `error_codes` (array)
- `error_context` (array)
For MVP warnings persistence:
- `error_context.warnings[]` (array of warning objects)
- Warning object shape (stable): `{type: 'unsupported_reference', policy_id, raw_ref, reason}`
### InventoryLink
Dependency edge storage.
Fields:
- `tenant_id`
- `source_type` (string; MVP uses `inventory_item`)
- `source_id` (string; stores `InventoryItem.external_id`)
- `target_type` (string; `inventory_item` | `foundation_object` | `missing`)
- `target_id` (nullable string; null when missing)
- `relationship_type` (string; values from RelationshipType enum)
- `metadata` (jsonb)
- timestamps
Unique key (idempotency):
- `(tenant_id, source_type, source_id, target_type, target_id, relationship_type)`
#### InventoryLink.metadata
Common keys:
- `last_known_name` (nullable string)
- `raw_ref` (mixed/array; only when safe)
Required when `target_type='foundation_object'`:
- `foundation_type` (string enum-like): `aad_group` | `scope_tag` | `device_category`
## Enums
### RelationshipType
- `assigned_to`
- `scoped_by`
- `targets`
- `depends_on`
## Relationships
- InventoryItem (source) has many outbound InventoryLinks via `source_id` + `tenant_id`.
- InventoryItem (target) has many inbound InventoryLinks via `target_id` + `tenant_id` where `target_type='inventory_item'`.
## Constraints / Limits
- Query: limit-only, ordered by `created_at DESC`.
- UI: max 50 per direction (<=100 combined).
- Extraction: max 50 outbound edges per item; unknown shapes are warning-only.

View File

@ -1,24 +1,100 @@
# Implementation Plan: Inventory Dependencies Graph # Implementation Plan: Inventory Dependencies Graph (042)
**Date**: 2026-01-07 **Branch**: `feat/042-inventory-dependencies-graph` | **Date**: 2026-01-10 | **Spec**: specs/042-inventory-dependencies-graph/spec.md
**Spec**: `specs/042-inventory-dependencies-graph/spec.md`
## Summary ## Summary
Add dependency edge model, extraction logic, and UI views to explain relationships between inventory items and prerequisite/foundation objects. Provide a read-only dependency view for an Inventory Item (direct inbound/outbound edges) with filters for direction and relationship type. Dependencies are derived from inventory sync payloads and stored idempotently in `inventory_links`.
## Dependencies ## MVP Constraints (Explicit)
- Inventory items and stable identifiers (Spec 040) - Direct neighbors only (no depth > 1 traversal / transitive blast radius).
- Inventory UI detail pages (Spec 041) or equivalent navigation - Limit-only queries (no pagination/cursors).
- UI shows <= 50 edges per direction (<= 100 total when showing both directions).
- Unknown/unsupported reference shapes are warning-only (no edge created).
- Warnings persist on `InventorySyncRun.error_context.warnings[]`.
- No new tables for warnings.
## Deliverables ## Technical Context
- Relationship taxonomy **Language/Version**: PHP 8.4.x
- Persisted dependency edges **Primary Dependencies**: Laravel 12, Filament v4, Livewire v3
- Query and rendering in UI **Storage**: PostgreSQL (JSONB)
**Testing**: Pest v4
**Target Platform**: Web (Filament admin)
**Project Type**: Laravel monolith
**Performance Goals**: dependency section renders in <2s with indexed + limited queries
**Constraints**: tenant scoped only; no extra Graph lookups for enrichment
**Scale/Scope**: edge rendering and extraction are hard-capped
## Risks ## Constitution Check
- Heterogeneous reference shapes across policy types *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Edge explosion for large tenants
- Inventory-first: edges reflect last observed sync payloads; no backups/snapshots.
- Read/write separation: UI is read-only; no Intune write paths.
- Single contract path to Graph: no new Graph calls for this feature.
- Tenant isolation: all edges stored/queried with `tenant_id`.
- Automation: idempotent via unique key + upsert; observable via run record; warnings persisted.
- Data minimization: only metadata stored; no secrets/tokens.
Gate status: PASS.
## Project Structure
### Documentation (this feature)
```text
specs/042-inventory-dependencies-graph/
├── plan.md
├── spec.md
├── tasks.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── README.md
│ └── dependency-edge.schema.json
└── checklists/
├── pr-gate.md
└── requirements.md
```
### Source Code (repository root)
```text
app/Filament/Resources/InventoryItemResource.php
app/Models/InventoryItem.php
app/Models/InventoryLink.php
app/Models/InventorySyncRun.php
app/Services/Inventory/DependencyExtractionService.php
app/Services/Inventory/DependencyQueryService.php
app/Support/Enums/RelationshipType.php
resources/views/filament/components/dependency-edges.blade.php
tests/Feature/InventoryItemDependenciesTest.php
tests/Feature/DependencyExtractionFeatureTest.php
tests/Unit/DependencyExtractionServiceTest.php
```
## Phase 0: Research (Output: research.md)
Document decisions + rationale + alternatives for MVP clarifications (limit-only, 50 per direction, warnings-on-run-record, warning-only unknown shapes, required foundation_type metadata, relationship-type filter).
## Phase 1: Design (Outputs: data-model.md, contracts/*, quickstart.md)
- Data model: entities and fields, including `inventory_links` unique key and metadata shapes.
- Contracts: JSON schema describing the dependency edge data passed to the UI.
- Quickstart: how to view dependencies and run targeted tests.
## Phase 2: Implementation Plan (MVP)
1. UI filters: direction + relationship-type via querystring.
2. Query: use DB filtering via `DependencyQueryService` optional `relationship_type`.
3. Extraction: align unknown/unsupported shapes to warning-only and persist warnings on run record.
4. Tests: add/adjust unit/feature/UI smoke tests for relationship filtering and warning-only behavior.
5. Quality gates: Pint + targeted Pest tests.
## Complexity Tracking
None for MVP (no constitution violations).
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |

View File

@ -0,0 +1,34 @@
# Quickstart — Inventory Dependencies Graph (042)
## Prerequisites
- Run the app via Sail.
- Ensure you have at least one tenant and inventory items.
## Viewing Dependencies
1. Navigate to **Inventory** → select an Inventory Item.
2. In the **Dependencies** section use the querystring-backed filters:
- `direction`: `all` (default) | `inbound` | `outbound`
- `relationship_type`: `all` (default) | `assigned_to` | `scoped_by` | `targets` | `depends_on`
Example URLs:
- `...?direction=outbound&relationship_type=scoped_by`
## Running the Targeted Tests
- UI smoke tests:
- `./vendor/bin/sail artisan test tests/Feature/InventoryItemDependenciesTest.php`
## MVP Notes
- Limit-only, no pagination.
- Shows <=50 edges per direction (<=100 total when showing both directions).
- Unknown/unsupported reference shapes are warning-only and should be visible via `InventorySyncRun.error_context.warnings[]`.
## Manual Performance Check (<2s)
1. Open an Inventory Item with ~50 inbound and/or ~50 outbound edges.
2. Use browser devtools Network tab to confirm the page request completes quickly.
3. Toggle `direction` and `relationship_type` filters and confirm responses remain fast.

View File

@ -0,0 +1,47 @@
# Research — Inventory Dependencies Graph (042)
This document resolves all implementation clarifications for the MVP and records the key decisions with rationale and alternatives.
## Decisions
### 1) Pagination vs limit-only
- Decision: **Limit-only** (no pagination/cursors in MVP).
- Rationale: Pagination introduces cursor semantics, UI states, sorting guarantees, and additional tests; MVP goal is fast/stable/testable.
- Alternatives considered:
- Add pagination now: rejected due to complexity and low MVP value.
### 2) Edge limits in UI
- Decision: **50 per direction** (inbound and outbound), so up to **100 total** when showing both directions.
- Rationale: Keeps each query bounded and predictable; matches existing UI composition (combine inbound + outbound).
- Alternatives considered:
- 50 total across both directions: rejected because it makes results direction-dependent and less intuitive.
### 3) Relationship-type filter (UI)
- Decision: Add **single-select Relationship filter** with default **All**; persists in querystring.
- Rationale: Small UX improvement with high usefulness; minimal risk.
- Alternatives considered:
- No relationship filter: rejected (spec requires it; improves scanability).
### 4) Unknown/unsupported reference shapes
- Decision: **Warning-only** (no edge created).
- Rationale: Creating “missing” edges for unknown shapes is misleading; it inflates perceived missing prerequisites and reduces trust.
- Alternatives considered:
- Create missing edge: rejected as potentially inaccurate.
### 5) Where warnings are stored
- Decision: Persist warnings on the **sync run record** at `InventorySyncRun.error_context.warnings[]`.
- Rationale: Auditable, debuggable, no new schema, consistent with “observable automation”.
- Alternatives considered:
- Per-item warnings in `InventoryItem.meta_jsonb`: rejected (pollutes inventory, harder to reason about run-level issues).
- New warnings table: rejected (migrations/models/retention/cleanup burden).
### 6) Foundation object typing
- Decision: For `target_type='foundation_object'`, always store `metadata.foundation_type`.
- Rationale: Deterministic UI labeling/resolution; avoids inference.
- Alternatives considered:
- Infer foundation type from relationship type: rejected (brittle).
## Notes / Implementation Implications
- If the current code path creates missing edges for unknown assignment shapes, it must be adjusted to **warning-only** to match spec.
- Warning payload shape should be stable: `{type: 'unsupported_reference', policy_id, raw_ref, reason}`.

View File

@ -8,6 +8,24 @@ ## Purpose
Represent and surface dependency relationships between inventory items and foundational Intune objects so admins can understand blast radius and prerequisites. 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 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 ## User Scenarios & Testing
### Scenario 1: View dependencies for an item ### Scenario 1: View dependencies for an item
@ -18,30 +36,132 @@ ### Scenario 1: View dependencies for an item
### Scenario 2: Identify missing prerequisites ### Scenario 2: Identify missing prerequisites
- Given an item references a prerequisite object not present in inventory - Given an item references a prerequisite object not present in inventory
- When the user views dependencies - When the user views dependencies
- Then missing prerequisites are clearly indicated - Then missing prerequisites are clearly indicated (red badge, "Missing" label, tooltip with last-known displayName if available)
### Scenario 3: Filter dependencies by relationship type ### 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 - Given multiple relationship types exist
- When the user filters by relationship type - When the user filters by relationship type (single-select dropdown, default: "All")
- Then only matching edges are shown - Then only matching edges are shown (empty selection = all edges visible)
### 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 ## Functional Requirements
- FR1: Define a normalized set of relationship types. - **FR1: Relationship taxonomy**
- FR2: Store dependency edges between inventory items and other objects (including non-inventory foundations when applicable). Define a normalized set of relationship types covering inventory→inventory and inventory→foundation edges.
- FR3: Allow querying inbound/outbound edges for a given item. Supported types (MVP):
- FR4: Show missing prerequisites without requiring a separate “deleted” state in core inventory. - `assigned_to` (Policy → AAD Group)
- FR5: All dependency data is tenant-scoped and access-controlled. - `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`)
**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 ## Non-Functional Requirements
- NFR1: Dependency extraction must be idempotent (re-runnable without duplicating edges). - **NFR1: Idempotency**
- NFR2: Dependency extraction must not fail an inventory sync run if an unknown/unsupported reference is encountered; it should record a safe warning. 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 ## Success Criteria
- SC1: Admins can determine prerequisites and blast radius for an item in under 2 minutes. - **SC1: Blast radius determination**
- SC2: For supported relationship types, dependency edges are consistent across re-runs (deterministic output). 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 ## Out of Scope

View File

@ -1,7 +1,198 @@
# Tasks: Inventory Dependencies Graph # Tasks: Inventory Dependencies Graph (042)
- [ ] T001 Define relationship taxonomy **Input**: Design documents in `specs/042-inventory-dependencies-graph/` (plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md)
- [ ] T002 Add dependency edge storage and indexes
- [ ] T003 Extraction pipeline (idempotent) **Notes**
- [ ] T004 Item-level dependencies UI - Tasks are grouped by user story so each story is independently testable.
- [ ] T005 Tests for edge determinism and tenant scoping - Tests are included because this feature affects runtime behavior.
- MVP constraints (direct only, limit-only, 50 per direction, warnings-on-run-record, no warnings tables) must remain enforced.
## Phase 1: Setup (Shared)
**Purpose**: Ensure feature docs and scope constraints are locked before code changes.
- [x] T001 Validate MVP constraints in `specs/042-inventory-dependencies-graph/plan.md` remain aligned with `specs/042-inventory-dependencies-graph/spec.md`
- [x] T002 Validate scope + NFR checkboxes in `specs/042-inventory-dependencies-graph/checklists/requirements.md` cover all accepted MVP constraints
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Storage + extraction + query services that all UI stories rely on.
**Checkpoint**: After Phase 2, edges can be extracted and queried tenant-safely with limits.
- [ ] T003 [P] Ensure relationship types and labels are centralized in `app/Support/Enums/RelationshipType.php`
- [ ] T004 Ensure `inventory_links` schema (unique key + indexes) matches spec in `database/migrations/2026_01_07_150000_create_inventory_links_table.php`
- [ ] T005 [P] Ensure `InventoryLink` casts and tenant-safe query patterns in `app/Models/InventoryLink.php`
- [ ] T006 [P] Ensure factory coverage for dependency edges in `database/factories/InventoryLinkFactory.php`
- [ ] T007 Align idempotent upsert semantics for edges in `app/Services/Inventory/DependencyExtractionService.php`
- [ ] T008 Implement warning-only handling for unknown/unsupported reference shapes in `app/Services/Inventory/DependencyExtractionService.php`
- [ ] T009 Persist warnings on the sync run record at `InventorySyncRun.error_context.warnings[]` via `app/Services/Inventory/InventorySyncService.php`
- [ ] T010 Implement limit-only inbound/outbound queries with optional relationship filter in `app/Services/Inventory/DependencyQueryService.php` (ordered by `created_at DESC`)
- [ ] T011 [P] Add determinism + idempotency tests in `tests/Unit/DependencyExtractionServiceTest.php`
- [ ] T012 [P] Add tenant isolation tests for queries in `tests/Feature/DependencyExtractionFeatureTest.php`
- [x] T003 [P] Ensure relationship types and labels are centralized in `app/Support/Enums/RelationshipType.php`
- [x] T004 Ensure `inventory_links` schema (unique key + indexes) matches spec in `database/migrations/2026_01_07_150000_create_inventory_links_table.php`
- [x] T005 [P] Ensure `InventoryLink` casts and tenant-safe query patterns in `app/Models/InventoryLink.php`
- [x] T006 [P] Ensure factory coverage for dependency edges in `database/factories/InventoryLinkFactory.php`
- [x] T007 Align idempotent upsert semantics for edges in `app/Services/Inventory/DependencyExtractionService.php`
- [x] T008 Implement warning-only handling for unknown/unsupported reference shapes in `app/Services/Inventory/DependencyExtractionService.php`
- [x] T009 Persist warnings on the sync run record at `InventorySyncRun.error_context.warnings[]` via `app/Services/Inventory/InventorySyncService.php`
- [x] T010 Implement limit-only inbound/outbound queries with optional relationship filter in `app/Services/Inventory/DependencyQueryService.php` (ordered by `created_at DESC`)
- [x] T011 [P] Add determinism + idempotency tests in `tests/Unit/DependencyExtractionServiceTest.php`
- [x] T012 [P] Add tenant isolation tests for queries in `tests/Feature/DependencyExtractionFeatureTest.php`
---
## Phase 3: User Story 1 — View Dependencies (Priority: P1) 🎯 MVP
**Goal**: As an admin, I can view direct inbound/outbound dependencies for an inventory item.
**Independent Test**: Opening an Inventory Item shows a Dependencies section that renders within limits and supports direction filtering.
- [ ] T013 [P] [US1] Wire dependencies section into Filament item view in `app/Filament/Resources/InventoryItemResource.php`
- [ ] T014 [P] [US1] Render edges grouped by relationship type in `resources/views/filament/components/dependency-edges.blade.php`
- [ ] T015 [US1] Add direction filter (querystring-backed) in `resources/views/filament/components/dependency-edges.blade.php`
- [ ] T016 [US1] Parse/validate `direction` and pass to query service in `app/Filament/Resources/InventoryItemResource.php`
- [ ] T017 [US1] Add UI smoke test for dependencies rendering + direction filter in `tests/Feature/InventoryItemDependenciesTest.php`
- [x] T013 [P] [US1] Wire dependencies section into Filament item view in `app/Filament/Resources/InventoryItemResource.php`
- [x] T014 [P] [US1] Render edges grouped by relationship type in `resources/views/filament/components/dependency-edges.blade.php`
- [x] T015 [US1] Add direction filter (querystring-backed) in `resources/views/filament/components/dependency-edges.blade.php`
- [x] T016 [US1] Parse/validate `direction` and pass to query service in `app/Filament/Resources/InventoryItemResource.php`
- [x] T017 [US1] Add UI smoke test for dependencies rendering + direction filter in `tests/Feature/InventoryItemDependenciesTest.php`
---
## Phase 4: User Story 2 — Identify Missing Prerequisites (Priority: P2)
**Goal**: As an admin, I can clearly see when a referenced prerequisite object is missing.
**Independent Test**: A missing target renders a red “Missing” badge and safe tooltip using `metadata.last_known_name`/`metadata.raw_ref`.
- [ ] T018 [US2] Ensure unresolved targets create `target_type='missing'` edges with safe metadata in `app/Services/Inventory/DependencyExtractionService.php`
- [ ] T019 [US2] Display “Missing” badge + tooltip for missing edges in `resources/views/filament/components/dependency-edges.blade.php`
- [ ] T020 [US2] Add feature test for missing edge creation + metadata in `tests/Feature/DependencyExtractionFeatureTest.php`
- [ ] T021 [US2] Add UI test asserting missing badge/tooltip is visible in `tests/Feature/InventoryItemDependenciesTest.php`
- [x] T018 [US2] Ensure unresolved targets create `target_type='missing'` edges with safe metadata in `app/Services/Inventory/DependencyExtractionService.php`
- [x] T019 [US2] Display “Missing” badge + tooltip for missing edges in `resources/views/filament/components/dependency-edges.blade.php`
- [x] T020 [US2] Add feature test for missing edge creation + metadata in `tests/Feature/DependencyExtractionFeatureTest.php`
- [x] T021 [US2] Add UI test asserting missing badge/tooltip is visible in `tests/Feature/InventoryItemDependenciesTest.php`
---
## Phase 5: User Story 3 — Filter By Relationship Type (Priority: P2)
**Goal**: As an admin, I can filter dependencies by relationship type to reduce noise.
**Independent Test**: Selecting a relationship type shows only matching edges; default “All” shows everything.
- [ ] T022 [US3] Add relationship-type dropdown filter (querystring-backed) in `resources/views/filament/components/dependency-edges.blade.php`
- [ ] T023 [US3] Parse/validate `relationship_type` and pass to query service in `app/Filament/Resources/InventoryItemResource.php`
- [ ] T024 [US3] Add UI smoke test verifying relationship filter limits edges in `tests/Feature/InventoryItemDependenciesTest.php`
- [x] T022 [US3] Add relationship-type dropdown filter (querystring-backed) in `resources/views/filament/components/dependency-edges.blade.php`
- [x] T023 [US3] Parse/validate `relationship_type` and pass to query service in `app/Filament/Resources/InventoryItemResource.php`
- [x] T024 [US3] Add UI smoke test verifying relationship filter limits edges in `tests/Feature/InventoryItemDependenciesTest.php`
---
## Phase 6: User Story 4 — Zero Dependencies (Priority: P3)
**Goal**: As an admin, I get a clear empty state when no dependencies exist.
**Independent Test**: When queries return zero edges, the UI shows “No dependencies found” and does not error.
- [ ] T025 [US4] Render zero-state message in `resources/views/filament/components/dependency-edges.blade.php`
- [ ] T026 [US4] Add UI test for zero-state rendering in `tests/Feature/InventoryItemDependenciesTest.php`
- [x] T025 [US4] Render zero-state message in `resources/views/filament/components/dependency-edges.blade.php`
- [x] T026 [US4] Add UI test for zero-state rendering in `tests/Feature/InventoryItemDependenciesTest.php`
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Tighten docs/contracts and run quality gates.
- [ ] T027 [P] Align filter docs + URLs in `specs/042-inventory-dependencies-graph/quickstart.md`
- [ ] T028 [P] Ensure schema reflects metadata requirements in `specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json`
- [ ] T029 Update requirement-gate checkboxes in `specs/042-inventory-dependencies-graph/checklists/pr-gate.md`
- [ ] T030 Run Pint and fix formatting in `app/`, `resources/views/filament/components/`, and `tests/` (touching `app/Support/Enums/RelationshipType.php`, `resources/views/filament/components/dependency-edges.blade.php`, `tests/Feature/InventoryItemDependenciesTest.php`)
- [ ] T031 Run targeted tests: `tests/Feature/InventoryItemDependenciesTest.php`, `tests/Feature/DependencyExtractionFeatureTest.php`, `tests/Unit/DependencyExtractionServiceTest.php`
- [x] T027 [P] Align filter docs + URLs in `specs/042-inventory-dependencies-graph/quickstart.md`
- [x] T028 [P] Ensure schema reflects metadata requirements in `specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json`
- [x] T029 Update requirement-gate checkboxes in `specs/042-inventory-dependencies-graph/checklists/pr-gate.md`
- [x] T030 Run Pint and fix formatting in `app/`, `resources/views/filament/components/`, and `tests/` (touching `app/Support/Enums/RelationshipType.php`, `resources/views/filament/components/dependency-edges.blade.php`, `tests/Feature/InventoryItemDependenciesTest.php`)
- [x] T031 Run targeted tests: `tests/Feature/InventoryItemDependenciesTest.php`, `tests/Feature/DependencyExtractionFeatureTest.php`, `tests/Unit/DependencyExtractionServiceTest.php`
---
## Phase 8: Consistency & Security Coverage (Cross-Cutting)
**Purpose**: Close remaining spec→tasks gaps (ordering, masking, auth expectations, logging severity).
- [ ] T032 [P] Add test asserting inbound/outbound query ordering by `created_at DESC` + limit-only behavior in `tests/Feature/DependencyExtractionFeatureTest.php` (or `tests/Unit/DependencyExtractionServiceTest.php` if more appropriate)
- [ ] T033 [US2] Implement masked/abbreviated identifier rendering when `metadata.last_known_name` is null in `resources/views/filament/components/dependency-edges.blade.php`
- [ ] T034 [US2] Add UI test for masked identifier behavior in `tests/Feature/InventoryItemDependenciesTest.php`
- [ ] T035 [P] Add auth behavior test for the inventory item dependencies view (guest blocked; authenticated tenant user allowed) in `tests/Feature/InventoryItemDependenciesTest.php`
- [ ] T036 [P] Ensure unknown/unsupported reference warnings are logged at `info` severity in `app/Services/Inventory/DependencyExtractionService.php` and add a unit test using `Log::fake()` in `tests/Unit/DependencyExtractionServiceTest.php`
- [ ] T037 Document how to verify the <2s dependency section goal (manual acceptance check) in `specs/042-inventory-dependencies-graph/quickstart.md`
- [x] T032 [P] Add test asserting inbound/outbound query ordering by `created_at DESC` + limit-only behavior in `tests/Feature/DependencyExtractionFeatureTest.php` (or `tests/Unit/DependencyExtractionServiceTest.php` if more appropriate)
- [x] T033 [US2] Implement masked/abbreviated identifier rendering when `metadata.last_known_name` is null in `resources/views/filament/components/dependency-edges.blade.php`
- [x] T034 [US2] Add UI test for masked identifier behavior in `tests/Feature/InventoryItemDependenciesTest.php`
- [x] T035 [P] Add auth behavior test for the inventory item dependencies view (guest blocked; authenticated tenant user allowed) in `tests/Feature/InventoryItemDependenciesTest.php`
- [x] T036 [P] Ensure unknown/unsupported reference warnings are logged at `info` severity in `app/Services/Inventory/DependencyExtractionService.php` and add a unit test using `Log::fake()` in `tests/Unit/DependencyExtractionServiceTest.php`
- [x] T037 Document how to verify the <2s dependency section goal (manual acceptance check) in `specs/042-inventory-dependencies-graph/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- Phase 1 (Setup) → Phase 2 (Foundational) → US1 (MVP) → US2/US3 → US4 → Polish
### User Story Dependencies
- US1 depends on Phase 2.
- US2 depends on Phase 2 and US1 (needs the Dependencies view).
- US3 depends on Phase 2 and US1 (needs the Dependencies view).
- US4 depends on US1 (zero-state is part of the Dependencies view).
## Parallel Execution Examples
### Phase 2 (Foundational)
- [P] `app/Support/Enums/RelationshipType.php` and `database/migrations/2026_01_07_150000_create_inventory_links_table.php`
- [P] `database/factories/InventoryLinkFactory.php` and `tests/Unit/DependencyExtractionServiceTest.php`
### User Story 1 (US1)
- [P] Update `app/Filament/Resources/InventoryItemResource.php` while implementing rendering in `resources/views/filament/components/dependency-edges.blade.php`
### User Story 2 (US2)
- [P] Implement missing-edge extraction in `app/Services/Inventory/DependencyExtractionService.php` while updating UI rendering in `resources/views/filament/components/dependency-edges.blade.php`
### User Story 3 (US3)
- [P] Implement relationship dropdown in `resources/views/filament/components/dependency-edges.blade.php` while wiring query parsing in `app/Filament/Resources/InventoryItemResource.php`
### User Story 4 (US4)
- [P] Implement zero-state UI in `resources/views/filament/components/dependency-edges.blade.php` while writing the UI assertion in `tests/Feature/InventoryItemDependenciesTest.php`
## Implementation Strategy
### MVP First (US1 Only)
1. Complete Phase 1 and Phase 2.
2. Implement US1 and validate with `tests/Feature/InventoryItemDependenciesTest.php`.
3. Stop and demo MVP UI before proceeding to US2/US3.
### Incremental Delivery
1. Phase 1 + Phase 2 → foundation ready.
2. US1 → demo dependency view.
3. US2 → add missing-prerequisite trust signals.
4. US3 → add relationship filtering for readability.
5. US4 → refine empty-state UX.

View File

@ -0,0 +1,275 @@
<?php
use App\Models\InventoryItem;
use App\Models\InventoryLink;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\InventorySyncService;
class FakeGraphClientForDeps implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, [
[
'id' => 'pol-1',
'displayName' => 'Test Policy',
'assignments' => [
['target' => ['groupId' => 'group-1']],
['target' => ['groupId' => 'group-2']],
],
'roleScopeTagIds' => ['scope-tag-1', 'scope-tag-2'],
],
], 200);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, [], 200);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, [], 200);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true, [], 200);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, [], 200);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true, [], 200);
}
}
it('extracts edges during inventory sync and marks missing appropriately', function () {
$tenant = Tenant::factory()->create();
$this->app->bind(GraphClientInterface::class, fn () => new FakeGraphClientForDeps);
$svc = app(InventorySyncService::class);
$run = $svc->syncNow($tenant, [
'policy_types' => ['deviceConfiguration'],
'categories' => [],
'include_foundations' => false,
'include_dependencies' => true,
]);
expect($run->status)->toBe('success');
$edges = InventoryLink::query()->where('tenant_id', $tenant->getKey())->get();
// 2 assigned_to + 2 scoped_by = 4
expect($edges->count())->toBe(4);
});
it('respects 50-edge limit for outbound extraction', function () {
$tenant = Tenant::factory()->create();
// Fake client returning 60 group assignments
$this->app->bind(GraphClientInterface::class, function () {
return new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
$assignments = [];
for ($i = 1; $i <= 60; $i++) {
$assignments[] = ['target' => ['groupId' => 'g-'.$i]];
}
return new GraphResponse(true, [[
'id' => 'pol-2',
'displayName' => 'Big Assignments',
'assignments' => $assignments,
]]);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
};
});
$svc = app(InventorySyncService::class);
$svc->syncNow($tenant, [
'policy_types' => ['deviceConfiguration'],
'categories' => [],
'include_foundations' => false,
'include_dependencies' => true,
]);
$count = InventoryLink::query()->where('tenant_id', $tenant->getKey())->count();
expect($count)->toBe(50);
});
it('persists unsupported reference warnings on the sync run record', function () {
$tenant = Tenant::factory()->create();
$this->app->bind(GraphClientInterface::class, function () {
return new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, [[
'id' => 'pol-warn-1',
'displayName' => 'Unsupported Assignment Target',
'assignments' => [
['target' => ['filterId' => 'filter-only-no-group']],
],
]]);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
};
});
$svc = app(InventorySyncService::class);
$run = $svc->syncNow($tenant, [
'policy_types' => ['deviceConfiguration'],
'categories' => [],
'include_foundations' => false,
'include_dependencies' => true,
]);
$warnings = $run->error_context['warnings'] ?? null;
expect($warnings)->toBeArray()->toHaveCount(1);
expect($warnings[0]['type'] ?? null)->toBe('unsupported_reference');
expect(InventoryLink::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
});
it('orders inbound/outbound edges by created_at desc and applies limit-only behavior', function () {
$tenant = Tenant::factory()->create();
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$svc = app(DependencyQueryService::class);
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'foundation_object',
'target_id' => '11111111-1111-1111-1111-111111111111',
'relationship_type' => 'assigned_to',
'created_at' => now()->subMinutes(10),
'updated_at' => now()->subMinutes(10),
]);
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'foundation_object',
'target_id' => '22222222-2222-2222-2222-222222222222',
'relationship_type' => 'assigned_to',
'created_at' => now()->subMinutes(5),
'updated_at' => now()->subMinutes(5),
]);
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'foundation_object',
'target_id' => '33333333-3333-3333-3333-333333333333',
'relationship_type' => 'assigned_to',
'created_at' => now()->subMinutes(1),
'updated_at' => now()->subMinutes(1),
]);
$outbound = $svc->getOutboundEdges($item, null, 2);
expect($outbound)->toHaveCount(2);
expect($outbound[0]->target_id)->toBe('33333333-3333-3333-3333-333333333333');
expect($outbound[1]->target_id)->toBe('22222222-2222-2222-2222-222222222222');
expect($outbound[0]->created_at->greaterThan($outbound[1]->created_at))->toBeTrue();
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'target_type' => 'inventory_item',
'target_id' => $item->external_id,
'relationship_type' => 'depends_on',
'created_at' => now()->subMinutes(9),
'updated_at' => now()->subMinutes(9),
]);
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'target_type' => 'inventory_item',
'target_id' => $item->external_id,
'relationship_type' => 'depends_on',
'created_at' => now()->subMinutes(2),
'updated_at' => now()->subMinutes(2),
]);
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
'target_type' => 'inventory_item',
'target_id' => $item->external_id,
'relationship_type' => 'depends_on',
'created_at' => now()->subMinutes(1),
'updated_at' => now()->subMinutes(1),
]);
$inbound = $svc->getInboundEdges($item, null, 2);
expect($inbound)->toHaveCount(2);
expect($inbound[0]->source_id)->toBe('cccccccc-cccc-cccc-cccc-cccccccccccc');
expect($inbound[1]->source_id)->toBe('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb');
expect($inbound[0]->created_at->greaterThan($inbound[1]->created_at))->toBeTrue();
});

View File

@ -0,0 +1,45 @@
<?php
use App\Models\InventoryItem;
use App\Models\InventoryLink;
use App\Models\Tenant;
use App\Services\Inventory\DependencyQueryService;
use Illuminate\Support\Str;
it('returns outbound and inbound edges filtered by tenant and direction', function () {
$tenant = Tenant::factory()->create();
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => (string) Str::uuid(),
]);
// Outbound edge for this item
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'foundation_object',
'target_id' => (string) Str::uuid(),
'relationship_type' => 'assigned_to',
]);
// Inbound edge pointing to this item as target
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => (string) Str::uuid(),
'target_type' => 'inventory_item',
'target_id' => $item->external_id,
'relationship_type' => 'depends_on',
]);
$svc = app(DependencyQueryService::class);
$outbound = $svc->getOutboundEdges($item);
$inbound = $svc->getInboundEdges($item);
expect($outbound->count())->toBe(1);
expect($inbound->count())->toBe(1);
});

View File

@ -0,0 +1,43 @@
<?php
use App\Models\InventoryItem;
use App\Models\InventoryLink;
use App\Models\Tenant;
use App\Services\Inventory\DependencyQueryService;
use Illuminate\Support\Str;
it('does not leak edges across tenants in service queries', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
/** @var InventoryItem $itemA */
$itemA = InventoryItem::factory()->create([
'tenant_id' => $tenantA->getKey(),
'external_id' => (string) Str::uuid(),
]);
// Edge for tenant A
InventoryLink::factory()->create([
'tenant_id' => $tenantA->getKey(),
'source_type' => 'inventory_item',
'source_id' => $itemA->external_id,
'target_type' => 'foundation_object',
'target_id' => (string) Str::uuid(),
'relationship_type' => 'assigned_to',
]);
// Edge for tenant B with same source/target ids but different tenant
InventoryLink::factory()->create([
'tenant_id' => $tenantB->getKey(),
'source_type' => 'inventory_item',
'source_id' => $itemA->external_id,
'target_type' => 'foundation_object',
'target_id' => (string) Str::uuid(),
'relationship_type' => 'assigned_to',
]);
$svc = app(DependencyQueryService::class);
$outboundA = $svc->getOutboundEdges($itemA);
expect($outboundA->count())->toBe(1);
});

View File

@ -0,0 +1,188 @@
<?php
use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem;
use App\Models\InventoryLink;
use App\Models\Tenant;
use Illuminate\Support\Str;
it('shows zero-state when no dependencies and shows missing badge when applicable', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => (string) Str::uuid(),
]);
// Zero state
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
$this->get($url)->assertOk()->assertSee('No dependencies found');
// Create a missing edge and assert badge appears
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' => 'Ghost Target',
'raw_ref' => ['example' => 'ref'],
],
]);
$this->get($url)
->assertOk()
->assertSee('Missing')
->assertSee('Last known: Ghost Target');
});
it('direction filter limits to outbound or inbound', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => (string) Str::uuid(),
]);
// Outbound only
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'foundation_object',
'target_id' => (string) Str::uuid(),
'relationship_type' => 'assigned_to',
]);
// Inbound only
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => (string) Str::uuid(),
'target_type' => 'inventory_item',
'target_id' => $item->external_id,
'relationship_type' => 'depends_on',
]);
$urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=outbound';
$this->get($urlOutbound)->assertOk()->assertDontSee('No dependencies found');
$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');
});
it('does not show edges from other tenants (tenant isolation)', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => (string) Str::uuid(),
]);
$otherTenant = Tenant::factory()->create();
// Same source_id, but different tenant_id: must not be rendered.
InventoryLink::factory()->create([
'tenant_id' => $otherTenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'missing',
'target_id' => null,
'relationship_type' => 'assigned_to',
'metadata' => ['last_known_name' => 'Other Tenant Edge'],
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
$this->get($url)
->assertOk()
->assertDontSee('Other Tenant Edge');
});
it('shows masked identifier when last known name is missing', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => (string) Str::uuid(),
]);
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'foundation_object',
'target_id' => '12345678-1234-1234-1234-123456789012',
'relationship_type' => 'assigned_to',
'metadata' => [
'last_known_name' => null,
],
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
$this->get($url)
->assertOk()
->assertSee('ID: 123456…');
});
it('blocks guest access to inventory item dependencies view', function () {
$tenant = Tenant::factory()->create();
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => (string) Str::uuid(),
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
$this->get($url)->assertRedirect();
});

View File

@ -0,0 +1,82 @@
<?php
use App\Models\InventoryItem;
use App\Models\InventoryLink;
use App\Models\Tenant;
use App\Services\Inventory\DependencyExtractionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('extracts deterministically and enforces unique key', function () {
$tenant = Tenant::factory()->create();
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => (string) Str::uuid(),
]);
$policyData = [
'id' => $item->external_id,
'assignments' => [
['target' => ['groupId' => 'group-1']],
['target' => ['groupId' => 'group-2']],
],
'roleScopeTagIds' => ['scope-tag-1', 'scope-tag-2'],
];
$svc = app(DependencyExtractionService::class);
$warnings1 = $svc->extractForPolicyData($item, $policyData);
$warnings2 = $svc->extractForPolicyData($item, $policyData); // re-run, should be idempotent
expect($warnings1)->toBeArray()->toBeEmpty();
expect($warnings2)->toBeArray()->toBeEmpty();
$edges = InventoryLink::query()->where('tenant_id', $tenant->getKey())->get();
expect($edges)->toHaveCount(4);
// Ensure uniqueness by tuple (source, target, type)
$tuples = $edges->map(fn ($e) => implode('|', [
$e->source_type, $e->source_id, $e->target_type, (string) $e->target_id, $e->relationship_type,
]))->unique();
expect($tuples->count())->toBe(4);
});
it('handles unsupported references by recording warnings (no edges)', function () {
$tenant = Tenant::factory()->create();
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => (string) Str::uuid(),
]);
$policyData = [
'id' => $item->external_id,
'assignments' => [
['target' => ['filterId' => 'filter-only-no-group']], // no groupId shape → missing
],
];
Log::spy();
$svc = app(DependencyExtractionService::class);
$warnings = $svc->extractForPolicyData($item, $policyData);
expect($warnings)->toBeArray()->toHaveCount(1);
expect($warnings[0]['type'] ?? null)->toBe('unsupported_reference');
expect($warnings[0]['policy_id'] ?? null)->toBe($item->external_id);
expect(InventoryLink::query()->count())->toBe(0);
Log::shouldHaveReceived('info')
->withArgs(fn (string $message, array $context) => $message === 'Unsupported reference shape encountered'
&& ($context['type'] ?? null) === 'unsupported_reference'
&& ($context['policy_id'] ?? null) === $item->external_id)
->once();
});

View File

@ -0,0 +1,52 @@
<?php
use App\Models\InventoryLink;
use App\Models\Tenant;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('enforces unique constraint on inventory_links', function () {
$tenant = Tenant::factory()->create();
$data = [
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => (string) Str::uuid(),
'target_type' => 'foundation_object',
'target_id' => (string) Str::uuid(),
'relationship_type' => 'assigned_to',
'metadata' => ['last_known_name' => 'X'],
];
InventoryLink::query()->create($data);
expect(function () use ($data) {
InventoryLink::query()->create($data);
})->toThrow(QueryException::class);
});
it('scopes edges by tenant at query level', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$sourceId = (string) Str::uuid();
InventoryLink::factory()->create([
'tenant_id' => $tenantA->getKey(),
'source_id' => $sourceId,
]);
InventoryLink::factory()->create([
'tenant_id' => $tenantB->getKey(),
'source_id' => $sourceId,
]);
$edgesA = InventoryLink::query()->where('tenant_id', $tenantA->getKey())->get();
$edgesB = InventoryLink::query()->where('tenant_id', $tenantB->getKey())->get();
expect($edgesA->count())->toBe(1);
expect($edgesB->count())->toBe(1);
});