feat(042): relationship filter + MVP clarifications

This commit is contained in:
Ahmed Darrazi 2026-01-10 00:17:15 +01:00
parent 5e70cdaad4
commit 667ebc619b
8 changed files with 173 additions and 16 deletions

View File

@ -6,6 +6,7 @@
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Inventory\DependencyQueryService; 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;
@ -78,14 +79,21 @@ public static function infolist(Schema $schema): Schema
->view('filament.components.dependency-edges') ->view('filament.components.dependency-edges')
->state(function (InventoryItem $record) { ->state(function (InventoryItem $record) {
$direction = request()->query('direction', 'all'); $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); $service = app(DependencyQueryService::class);
$edges = collect(); $edges = collect();
if ($direction === 'inbound' || $direction === 'all') { if ($direction === 'inbound' || $direction === 'all') {
$edges = $edges->merge($service->getInboundEdges($record)); $edges = $edges->merge($service->getInboundEdges($record, $relationshipType));
} }
if ($direction === 'outbound' || $direction === 'all') { 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 return $edges->take(100); // both directions combined

View File

@ -8,4 +8,28 @@ enum RelationshipType: string
case ScopedBy = 'scoped_by'; case ScopedBy = 'scoped_by';
case Targets = 'targets'; case Targets = 'targets';
case DependsOn = 'depends_on'; 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

@ -8,6 +8,14 @@
<option value="inbound" {{ request('direction') === 'inbound' ? 'selected' : '' }}>Inbound</option> <option value="inbound" {{ request('direction') === 'inbound' ? 'selected' : '' }}>Inbound</option>
<option value="outbound" {{ request('direction') === 'outbound' ? 'selected' : '' }}>Outbound</option> <option value="outbound" {{ request('direction') === 'outbound' ? 'selected' : '' }}>Outbound</option>
</select> </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> <button type="submit" class="fi-btn">Apply</button>
</form> </form>

View File

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

View File

@ -7,6 +7,12 @@ ## Summary
Add dependency edge model, extraction logic, and UI views to explain relationships between inventory items and prerequisite/foundation objects. 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 ## Dependencies
- Inventory items and stable identifiers (Spec 040) - 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) - **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 - **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 ### Hard Limits
- **Max 50 edges per item per direction** (outbound/inbound) - **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 - 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']) ->options(['all' => 'All', 'inbound' => 'Inbound', 'outbound' => 'Outbound'])
->default('all') ->default('all')
->live(), ->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) // Edges table (or custom Blade component)
ViewEntry::make('edges') 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 - **Privacy**: If display name unavailable, render masked identifier (e.g., `ID: abcd12…`), no cross-tenant lookups
### Filter Behavior ### Filter Behavior
- Single-select dropdown (not multi-select) - Direction: single-select dropdown; default "All" (both inbound + outbound shown); empty/null treated as "All"
- Default: "All" (both inbound + outbound shown) - Relationship type: single-select dropdown; default "All"; empty/null treated as "All"
- Empty/null selection → treated as "All"
--- ---

View File

@ -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. 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**: **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. - **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). - **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). - **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_type` (string: `inventory_item`, `foundation_object`, `missing`)
- `target_id` (UUID or stable ref, nullable if missing) - `target_id` (UUID or stable ref, nullable if missing)
- `relationship_type` (FK to taxonomy or enum) - `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` - `created_at`, `updated_at`
**In-scope foundation object types (MVP)**: **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 - `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 - `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** - **FR4: Missing prerequisites**
When a target reference cannot be resolved: 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. 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** - **FR5: Tenant scoping and access control**
- All edges filtered by `tenant_id` matching `Tenant::current()` - All edges filtered by `tenant_id` matching `Tenant::current()`
- Read access: any authenticated tenant user - Read access: any authenticated tenant user
@ -107,23 +123,23 @@ ## Non-Functional Requirements
- **NFR2: Graceful unknown-reference handling** - **NFR2: Graceful unknown-reference handling**
If an unknown/unsupported reference shape is encountered: If an unknown/unsupported reference shape is encountered:
- Log warning with severity `info` (not `error`) - Log warning with severity `info` (not `error`)
- Do NOT create an edge for unsupported types - Do NOT create an edge for unsupported types (including unknown assignment target shapes)
- Record warning in sync run metadata: `{type: 'unsupported_reference', policy_id, raw_ref, reason}` - 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 - 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). - Depth > 1 traversal (transitive “blast radius”) is out of scope for this iteration.
- Traversal uses visited-node tracking to prevent revisiting nodes (cycle break); cycles are implicitly handled by not re-visiting. - The UI shows only direct inbound/outbound edges.
- No special UI cycle annotation in MVP; future work may visualize cycles explicitly. - Future work may add depth-capped traversal with cycle handling and explicit cycle visualization.
## Success Criteria ## Success Criteria
- **SC1: Blast radius determination** - **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 - 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?" - 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** - **SC2: Deterministic output**
For supported relationship types, dependency edges are consistent across re-runs: For supported relationship types, dependency edges are consistent across re-runs:

View File

@ -35,3 +35,14 @@ ## Finalization
- [x] T022 Run full test suite (`php artisan test`) - [x] T022 Run full test suite (`php artisan test`)
- [x] T023 Run Pint (`vendor/bin/pint`) - [x] T023 Run Pint (`vendor/bin/pint`)
- [x] T024 Update checklist items in `checklists/pr-gate.md` - [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.

View File

@ -69,3 +69,43 @@
$urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=inbound'; $urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=inbound';
$this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found'); $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');
});