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\Tenant;
use App\Services\Inventory\DependencyQueryService;
use App\Support\Enums\RelationshipType;
use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
@ -78,14 +79,21 @@ public static function infolist(Schema $schema): Schema
->view('filament.components.dependency-edges')
->state(function (InventoryItem $record) {
$direction = request()->query('direction', 'all');
$relationshipType = request()->query('relationship_type', 'all');
$relationshipType = is_string($relationshipType) ? $relationshipType : 'all';
$relationshipType = $relationshipType === 'all'
? null
: RelationshipType::tryFrom($relationshipType)?->value;
$service = app(DependencyQueryService::class);
$edges = collect();
if ($direction === 'inbound' || $direction === 'all') {
$edges = $edges->merge($service->getInboundEdges($record));
$edges = $edges->merge($service->getInboundEdges($record, $relationshipType));
}
if ($direction === 'outbound' || $direction === 'all') {
$edges = $edges->merge($service->getOutboundEdges($record));
$edges = $edges->merge($service->getOutboundEdges($record, $relationshipType));
}
return $edges->take(100); // both directions combined

View File

@ -8,4 +8,28 @@ enum RelationshipType: string
case ScopedBy = 'scoped_by';
case Targets = 'targets';
case DependsOn = 'depends_on';
public function label(): string
{
return match ($this) {
self::AssignedTo => 'Assigned to',
self::ScopedBy => 'Scoped by',
self::Targets => 'Targets',
self::DependsOn => 'Depends on',
};
}
/**
* @return array<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="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>

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.
**MVP constraints (explicit):**
- **Limit-only** queries (no pagination/cursors in this iteration).
- UI shows up to **50 edges per direction** (up to 100 total when showing both directions).
- Unknown/unsupported reference shapes: **warning-only** (no edge created).
- Warnings are persisted on the **sync run record** at `InventorySyncRun.error_context.warnings[]` (no new tables).
## Dependencies
- Inventory items and stable identifiers (Spec 040)
@ -63,6 +69,8 @@ ### Error & Warning Logic
- **Unresolved target**: Create edge with `target_type='missing'` (not an error)
- **All targets missing**: Extraction still emits `missing` edges; UI must handle zero resolvable targets without error
**MVP clarification:** Unknown/unsupported reference shapes are **warning-only** (no edge created). Warnings are stored at `InventorySyncRun.error_context.warnings[]`.
### Hard Limits
- **Max 50 edges per item per direction** (outbound/inbound)
- Enforced at extraction: sort by priority (assigned_to > scoped_by > targets > depends_on), keep top 50, log truncation warning
@ -126,6 +134,12 @@ ### Component Structure
->options(['all' => 'All', 'inbound' => 'Inbound', 'outbound' => 'Outbound'])
->default('all')
->live(),
// Filter: Relationship Type (default: All)
Forms\Components\Select::make('relationship_type')
->options(['all' => 'All'] + /* RelationshipType enum options */ [])
->default('all')
->live(),
// Edges table (or custom Blade component)
ViewEntry::make('edges')
@ -145,9 +159,8 @@ ### Blade View: `resources/views/filament/components/dependency-edges.blade.php`
- **Privacy**: If display name unavailable, render masked identifier (e.g., `ID: abcd12…`), no cross-tenant lookups
### Filter Behavior
- Single-select dropdown (not multi-select)
- Default: "All" (both inbound + outbound shown)
- Empty/null selection → treated as "All"
- Direction: single-select dropdown; default "All" (both inbound + outbound shown); empty/null treated as "All"
- Relationship type: single-select dropdown; default "All"; empty/null treated as "All"
---

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.
MVP shows direct inbound/outbound edges only; depth > 1 traversal is out of scope for this iteration.
## Clarifications
### Session 2026-01-10
- Q: Should FR3 be paginated or limit-only for MVP? → A: Limit-only (no pagination).
- Q: Where should unknown/unsupported reference warnings be persisted? → A: On the inventory sync run record (e.g., `InventorySyncRun.error_context.warnings[]`).
- Q: For unknown assignment target shapes, should we create a missing edge or warning-only? → A: Warning-only (no edge created).
- Q: Should `foundation_object` edges always store `metadata.foundation_type`? → A: Yes (required).
- Q: Should the UI show 50 edges total or 50 per direction? → A: 50 per direction (up to 100 total when showing both directions).
**Definitions**:
- **Blast radius**: All resources directly or transitively affected by a change to a given item (outbound edges up to depth 2).
- **Blast radius**: All resources directly affected by a change to a given item (outbound edges only; no transitive traversal in MVP).
- **Prerequisite**: A hard dependency required for an item to function; missing prerequisites are explicitly surfaced.
- **Inbound edge**: A relationship pointing TO this item (e.g., "Policy A is assigned to Group X" → Group X has inbound edge from Policy A).
- **Outbound edge**: A relationship pointing FROM this item (e.g., "Policy A is scoped by ScopeTag Y" → Policy A has outbound edge to ScopeTag Y).
@ -66,7 +78,7 @@ ## Functional Requirements
- `target_type` (string: `inventory_item`, `foundation_object`, `missing`)
- `target_id` (UUID or stable ref, nullable if missing)
- `relationship_type` (FK to taxonomy or enum)
- `metadata` (JSONB, optional: last_known_name, raw_ref, etc.)
- `metadata` (JSONB, optional: last_known_name, raw_ref, etc.; for `target_type='foundation_object'`, `metadata.foundation_type` is required)
- `created_at`, `updated_at`
**In-scope foundation object types (MVP)**:
@ -81,7 +93,9 @@ ## Functional Requirements
- `getOutboundEdges(item_id, relationship_type?, limit=50)` → returns edges where item is source
- `getInboundEdges(item_id, relationship_type?, limit=50)` → returns edges where item is target
Both return paginated, ordered by `created_at DESC`.
Both return up to `limit` edges, ordered by `created_at DESC`.
UI supports filtering by `relationship_type` via a single-select dropdown (default: "All"; empty selection behaves as "All").
- **FR4: Missing prerequisites**
When a target reference cannot be resolved:
@ -91,6 +105,8 @@ ## Functional Requirements
No separate "deleted" or "archived" state in core inventory; missing is purely an edge property.
Unknown/unsupported reference shapes do not create edges; they are handled via warnings (see NFR2).
- **FR5: Tenant scoping and access control**
- All edges filtered by `tenant_id` matching `Tenant::current()`
- Read access: any authenticated tenant user
@ -107,23 +123,23 @@ ## Non-Functional Requirements
- **NFR2: Graceful unknown-reference handling**
If an unknown/unsupported reference shape is encountered:
- Log warning with severity `info` (not `error`)
- Do NOT create an edge for unsupported types
- Record warning in sync run metadata: `{type: 'unsupported_reference', policy_id, raw_ref, reason}`
- Do NOT create an edge for unsupported types (including unknown assignment target shapes)
- Record warning in sync run metadata at `InventorySyncRun.error_context.warnings[]` with shape: `{type: 'unsupported_reference', policy_id, raw_ref, reason}`
- Sync run continues without failure
## Graph Traversal & Cycles
## Graph Traversal & Cycles (Out of Scope for MVP)
- UI blast radius view is limited to depth ≤ 2 (direct neighbors and their neighbors).
- Traversal uses visited-node tracking to prevent revisiting nodes (cycle break); cycles are implicitly handled by not re-visiting.
- No special UI cycle annotation in MVP; future work may visualize cycles explicitly.
- Depth > 1 traversal (transitive “blast radius”) is out of scope for this iteration.
- The UI shows only direct inbound/outbound edges.
- Future work may add depth-capped traversal with cycle handling and explicit cycle visualization.
## Success Criteria
- **SC1: Blast radius determination**
Admins can determine prerequisites (inbound edges) and blast radius (outbound edges, depth ≤2) for any item in under 2 minutes:
Admins can determine prerequisites (inbound edges) and blast radius (outbound edges; direct only) for any item in under 2 minutes:
- Measured from: clicking "View Dependencies" on an item detail page
- To: able to answer "What would break if I delete this?" and "What does this depend on?"
- Acceptance: <2s page load, 50 edges per direction shown initially, clear visual grouping by relationship type
- Acceptance: <2s page load, 50 edges per direction shown initially (100 total when showing both directions), clear visual grouping by relationship type
- **SC2: Deterministic output**
For supported relationship types, dependency edges are consistent across re-runs:

View File

@ -35,3 +35,14 @@ ## Finalization
- [x] T022 Run full test suite (`php artisan test`)
- [x] T023 Run Pint (`vendor/bin/pint`)
- [x] T024 Update checklist items in `checklists/pr-gate.md`
## 追加 Tasks (MVP Remediation)
- [ ] T025 Implement relationship-type filter (single-select dropdown, default: all)
- [ ] T026 UI Smoke: relationship-type filter limits edges
- [ ] T027 Create and complete `checklists/requirements.md` (Constitution gate)
## MVP Constraints (Non-Tasks)
- MVP is limit-only (no pagination/cursors).
- Show up to 50 edges per direction (up to 100 total for "all").
- Unknown/unsupported shapes are warning-only; persist warnings on run record (`InventorySyncRun.error_context.warnings[]`).
- No new tables for warnings.

View File

@ -69,3 +69,43 @@
$urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=inbound';
$this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found');
});
it('relationship filter limits edges by type', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => (string) Str::uuid(),
]);
// Two outbound edges with different relationship types.
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'missing',
'target_id' => null,
'relationship_type' => 'assigned_to',
'metadata' => ['last_known_name' => 'Assigned Target'],
]);
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'missing',
'target_id' => null,
'relationship_type' => 'scoped_by',
'metadata' => ['last_known_name' => 'Scoped Target'],
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant)
.'?direction=outbound&relationship_type=scoped_by';
$this->get($url)
->assertOk()
->assertSee('Scoped Target')
->assertDontSee('Assigned Target');
});