feat/042-inventory-dependencies-graph #50
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -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 -->
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Support\Enums\RelationshipType;
|
use App\Support\Enums\RelationshipType;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class DependencyExtractionService
|
class DependencyExtractionService
|
||||||
{
|
{
|
||||||
@ -16,11 +17,12 @@ class DependencyExtractionService
|
|||||||
*
|
*
|
||||||
* @param array<string, mixed> $policyData
|
* @param array<string, mixed> $policyData
|
||||||
*/
|
*/
|
||||||
public function extractForPolicyData(InventoryItem $item, array $policyData): void
|
public function extractForPolicyData(InventoryItem $item, array $policyData): array
|
||||||
{
|
{
|
||||||
|
$warnings = [];
|
||||||
$edges = collect();
|
$edges = collect();
|
||||||
|
|
||||||
$edges = $edges->merge($this->extractAssignedTo($item, $policyData));
|
$edges = $edges->merge($this->extractAssignedTo($item, $policyData, $warnings));
|
||||||
$edges = $edges->merge($this->extractScopedBy($item, $policyData));
|
$edges = $edges->merge($this->extractScopedBy($item, $policyData));
|
||||||
|
|
||||||
// Enforce max 50 outbound edges by priority: assigned_to > scoped_by > others
|
// Enforce max 50 outbound edges by priority: assigned_to > scoped_by > others
|
||||||
@ -57,13 +59,15 @@ public function extractForPolicyData(InventoryItem $item, array $policyData): vo
|
|||||||
['metadata', 'updated_at']
|
['metadata', 'updated_at']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $policyData
|
* @param array<string, mixed> $policyData
|
||||||
* @return Collection<int, array<string, mixed>>
|
* @return Collection<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
private function extractAssignedTo(InventoryItem $item, array $policyData): Collection
|
private function extractAssignedTo(InventoryItem $item, array $policyData, array &$warnings): Collection
|
||||||
{
|
{
|
||||||
$assignments = Arr::get($policyData, 'assignments');
|
$assignments = Arr::get($policyData, 'assignments');
|
||||||
if (! is_array($assignments)) {
|
if (! is_array($assignments)) {
|
||||||
@ -93,19 +97,15 @@ private function extractAssignedTo(InventoryItem $item, array $policyData): Coll
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
// Unresolved/unknown target → mark missing
|
$warning = [
|
||||||
$edges[] = [
|
'type' => 'unsupported_reference',
|
||||||
'tenant_id' => (int) $item->tenant_id,
|
'policy_id' => (string) ($policyData['id'] ?? $item->external_id),
|
||||||
'source_type' => 'inventory_item',
|
'raw_ref' => $assignment,
|
||||||
'source_id' => (string) $item->external_id,
|
'reason' => 'unsupported_assignment_target_shape',
|
||||||
'target_type' => 'missing',
|
|
||||||
'target_id' => null,
|
|
||||||
'relationship_type' => RelationshipType::AssignedTo->value,
|
|
||||||
'metadata' => [
|
|
||||||
'raw_ref' => $assignment,
|
|
||||||
'last_known_name' => null,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$warnings[] = $warning;
|
||||||
|
Log::info('Unsupported reference shape encountered', $warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -108,6 +108,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();
|
||||||
@ -186,8 +187,11 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
|||||||
// Extract dependencies if requested in selection
|
// Extract dependencies if requested in selection
|
||||||
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true);
|
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true);
|
||||||
if ($includeDeps) {
|
if ($includeDeps) {
|
||||||
app(\App\Services\Inventory\DependencyExtractionService::class)
|
$warnings = array_merge(
|
||||||
->extractForPolicyData($item, $policyData);
|
$warnings,
|
||||||
|
app(\App\Services\Inventory\DependencyExtractionService::class)
|
||||||
|
->extractForPolicyData($item, $policyData)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -198,7 +202,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,
|
||||||
@ -207,11 +213,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,
|
||||||
|
|||||||
@ -38,11 +38,23 @@
|
|||||||
$name = $edge['metadata']['last_known_name'] ?? null;
|
$name = $edge['metadata']['last_known_name'] ?? null;
|
||||||
$targetId = $edge['target_id'] ?? null;
|
$targetId = $edge['target_id'] ?? null;
|
||||||
$display = $name ?: ($targetId ? ("ID: ".substr($targetId,0,6)."…") : 'Unknown');
|
$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
|
@endphp
|
||||||
<li class="flex items-center gap-2 text-sm">
|
<li class="flex items-center gap-2 text-sm">
|
||||||
<span class="fi-badge">{{ $display }}</span>
|
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $display }}</span>
|
||||||
@if ($isMissing)
|
@if ($isMissing)
|
||||||
<span class="fi-badge fi-badge-danger" title="Missing target">Missing</span>
|
<span class="fi-badge fi-badge-danger" title="{{ $missingTitle }}">Missing</span>
|
||||||
@endif
|
@endif
|
||||||
</li>
|
</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@ -2,36 +2,36 @@ # Requirements Checklist — Inventory Dependencies Graph (042)
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
- [ ] This checklist applies only to Spec 042 (Inventory Dependencies Graph).
|
- [x] 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).
|
- [x] MVP scope: show **direct** inbound/outbound edges only (no depth>1 traversal / transitive blast radius).
|
||||||
|
|
||||||
## Constitution Gates
|
## Constitution Gates
|
||||||
|
|
||||||
- [ ] Inventory-first: edges derived from last-observed inventory data (no snapshot/backup side effects)
|
- [x] Inventory-first: edges derived from last-observed inventory data (no snapshot/backup side effects)
|
||||||
- [ ] Read/write separation: no Intune write paths introduced
|
- [x] Read/write separation: no Intune write paths introduced
|
||||||
- [ ] Single contract path to Graph: Graph access only via GraphClientInterface + contracts (if used)
|
- [x] Single contract path to Graph: Graph access only via GraphClientInterface + contracts (if used)
|
||||||
- [ ] Tenant isolation: all reads/writes tenant-scoped
|
- [x] Tenant isolation: all reads/writes tenant-scoped
|
||||||
- [ ] Automation is idempotent & observable: unique key + upsert + run records + stable error codes
|
- [x] 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
|
- [x] 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[]
|
- [x] No new tables for warnings; warnings persist on InventorySyncRun.error_context.warnings[]
|
||||||
|
|
||||||
## Functional Requirements Coverage
|
## Functional Requirements Coverage
|
||||||
|
|
||||||
- [ ] FR-001 Relationship taxonomy exists and is testable (labels, directionality, descriptions)
|
- [x] FR-001 Relationship taxonomy exists and is testable (labels, directionality, descriptions)
|
||||||
- [ ] FR-002 Dependency edges stored in `inventory_links` with unique key (idempotent upsert)
|
- [x] 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)
|
- [x] 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
|
- [x] 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”)
|
- [x] FR-005 Relationship-type filtering available in UI (single-select, default “All”)
|
||||||
|
|
||||||
## Non-Functional Requirements Coverage
|
## Non-Functional Requirements Coverage
|
||||||
|
|
||||||
- [ ] NFR-001 Idempotency: re-running extraction does not create duplicates; updates metadata deterministically
|
- [x] 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
|
- [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)
|
## Tests (Pest)
|
||||||
|
|
||||||
- [ ] Extraction determinism + unique key (re-run equality)
|
- [x] Extraction determinism + unique key (re-run equality)
|
||||||
- [ ] Missing edges show “Missing” badge and safe tooltip
|
- [x] Missing edges show “Missing” badge and safe tooltip
|
||||||
- [ ] 50-edge limit enforced and truncation behavior is observable (if specified)
|
- [x] 50-edge limit enforced and truncation behavior is observable (if specified)
|
||||||
- [ ] Tenant isolation for queries and UI
|
- [x] Tenant isolation for queries and UI
|
||||||
- [ ] UI smoke: relationship-type filter limits visible edges
|
- [x] UI smoke: relationship-type filter limits visible edges
|
||||||
|
|||||||
@ -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.
|
||||||
@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
72
specs/042-inventory-dependencies-graph/data-model.md
Normal file
72
specs/042-inventory-dependencies-graph/data-model.md
Normal 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.
|
||||||
@ -1,219 +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`.
|
||||||
|
|
||||||
**MVP constraints (explicit):**
|
## 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
|
- Direct neighbors only (no depth > 1 traversal / transitive blast radius).
|
||||||
|
- 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.
|
||||||
|
|
||||||
- Inventory items and stable identifiers (Spec 040)
|
## Technical Context
|
||||||
- Inventory UI detail pages (Spec 041) or equivalent navigation
|
|
||||||
- Assignments payload (from Spec 006 or equivalent) for `assigned_to` relationships
|
|
||||||
- Scope tags and device categories as foundation objects
|
|
||||||
|
|
||||||
## Deliverables
|
**Language/Version**: PHP 8.4.x
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3
|
||||||
|
**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
|
||||||
|
|
||||||
- Relationship taxonomy (enum + metadata)
|
## Constitution Check
|
||||||
- Persisted dependency edges (`inventory_links` table)
|
|
||||||
- Extraction pipeline (integrated into inventory sync)
|
|
||||||
- Query service methods (inbound/outbound)
|
|
||||||
- UI components (Filament section + filtering)
|
|
||||||
- Tests (unit, feature, UI smoke, tenant isolation)
|
|
||||||
|
|
||||||
## Risks
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
- Heterogeneous reference shapes across policy types → **Mitigation**: Normalize references early; unsupported shapes → soft warning, skip edge creation
|
- Inventory-first: edges reflect last observed sync payloads; no backups/snapshots.
|
||||||
- Edge explosion for large tenants → **Mitigation**: Hard limit ≤50 edges per item per direction (enforced at extraction); pagination UI for future
|
- 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.
|
||||||
|
|
||||||
## 1. Extraction Pipeline
|
## Project Structure
|
||||||
|
|
||||||
### Timing & Integration
|
### Documentation (this feature)
|
||||||
- **When**: Dependencies extracted **during inventory sync run** (not on-demand)
|
|
||||||
- **Hook**: After `InventorySyncService` creates/updates `InventoryItem`, call `DependencyExtractionService`
|
|
||||||
- **Idempotency**: Each sync run reconciles edges for synced items (upsert based on unique key)
|
|
||||||
|
|
||||||
### Inputs
|
```text
|
||||||
1. **InventoryItem** (`raw`, `meta`, `resource_type`, `external_id`)
|
specs/042-inventory-dependencies-graph/
|
||||||
2. **Assignments payload** (if available): AAD Group IDs
|
├── plan.md
|
||||||
3. **Scope tags** (from item `meta->scopeTags` or raw payload)
|
├── spec.md
|
||||||
4. **Device categories** (from conditional logic in raw payload, if applicable)
|
├── tasks.md
|
||||||
|
├── research.md
|
||||||
### Extraction Logic
|
├── data-model.md
|
||||||
```php
|
├── quickstart.md
|
||||||
DependencyExtractionService::extractForItem(InventoryItem $item): array
|
├── contracts/
|
||||||
```
|
│ ├── README.md
|
||||||
- Parse `$item->raw` and `$item->meta` for known reference shapes
|
│ └── dependency-edge.schema.json
|
||||||
- For each reference:
|
└── checklists/
|
||||||
- Normalize to `{type, external_id, display_name?}`
|
├── pr-gate.md
|
||||||
- Determine `relationship_type` (assigned_to, scoped_by, targets, depends_on)
|
└── requirements.md
|
||||||
- Resolve target:
|
|
||||||
- If `InventoryItem` exists with matching `external_id` → `target_type='inventory_item'`
|
|
||||||
- If foundation object (AAD Group, Scope Tag, Device Category) → `target_type='foundation_object'`
|
|
||||||
- If unresolvable → `target_type='missing'`, `target_id=null`, store `metadata.last_known_name`
|
|
||||||
- Create/update edge
|
|
||||||
|
|
||||||
### Error & Warning Logic
|
|
||||||
- **Unsupported reference shape**: Log warning (`info` severity), record in sync run metadata, skip edge creation
|
|
||||||
- **Low confidence**: If reference parsing heuristic uncertain, create edge with `metadata.confidence='low'`
|
|
||||||
- **Unresolved target**: Create edge with `target_type='missing'` (not an error)
|
|
||||||
- **All targets missing**: Extraction still emits `missing` edges; UI must handle zero resolvable targets without error
|
|
||||||
|
|
||||||
**MVP clarification:** Unknown/unsupported reference shapes are **warning-only** (no edge created). Warnings are stored at `InventorySyncRun.error_context.warnings[]`.
|
|
||||||
|
|
||||||
### Hard Limits
|
|
||||||
- **Max 50 edges per item per direction** (outbound/inbound)
|
|
||||||
- Enforced at extraction: sort by priority (assigned_to > scoped_by > targets > depends_on), keep top 50, log truncation warning
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Storage & Upsert Details
|
|
||||||
|
|
||||||
### Schema: `inventory_links` Table
|
|
||||||
```php
|
|
||||||
Schema::create('inventory_links', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
|
||||||
$table->string('source_type'); // 'inventory_item', 'foundation_object'
|
|
||||||
$table->uuid('source_id'); // InventoryItem.id or foundation stable ID
|
|
||||||
$table->string('target_type'); // 'inventory_item', 'foundation_object', 'missing'
|
|
||||||
$table->uuid('target_id')->nullable(); // null if target_type='missing'
|
|
||||||
$table->string('relationship_type'); // 'assigned_to', 'scoped_by', etc.
|
|
||||||
$table->jsonb('metadata')->nullable(); // {last_known_name, raw_ref, confidence, ...}
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
// Unique key (idempotency)
|
|
||||||
$table->unique([
|
|
||||||
'tenant_id',
|
|
||||||
'source_type',
|
|
||||||
'source_id',
|
|
||||||
'target_type',
|
|
||||||
'target_id',
|
|
||||||
'relationship_type'
|
|
||||||
], 'inventory_links_unique');
|
|
||||||
|
|
||||||
$table->index(['tenant_id', 'source_type', 'source_id']);
|
|
||||||
$table->index(['tenant_id', 'target_type', 'target_id']);
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Upsert Strategy
|
### Source Code (repository root)
|
||||||
- **On extraction**: `InventoryLink::upsert($edges, uniqueBy: ['tenant_id', 'source_type', 'source_id', 'target_type', 'target_id', 'relationship_type'], update: ['metadata', 'updated_at'])`
|
|
||||||
- **On item deletion**: Edges **NOT** auto-deleted (orphan cleanup is manual or scheduled job)
|
|
||||||
- **On re-run**: Existing edges updated (`updated_at` bumped), new edges created
|
|
||||||
|
|
||||||
### Hard Limit Enforcement
|
```text
|
||||||
- Extraction service limits to 50 edges per direction before upserting
|
app/Filament/Resources/InventoryItemResource.php
|
||||||
- Query methods (`getOutboundEdges`, `getInboundEdges`) have default `limit=50` parameter
|
app/Models/InventoryItem.php
|
||||||
|
app/Models/InventoryLink.php
|
||||||
---
|
app/Models/InventorySyncRun.php
|
||||||
|
app/Services/Inventory/DependencyExtractionService.php
|
||||||
## 3. UI Components (Filament)
|
app/Services/Inventory/DependencyQueryService.php
|
||||||
|
app/Support/Enums/RelationshipType.php
|
||||||
### Location
|
resources/views/filament/components/dependency-edges.blade.php
|
||||||
- **InventoryItemResource → ViewInventoryItem page**
|
tests/Feature/InventoryItemDependenciesTest.php
|
||||||
- New section: "Dependencies" (below "Details" section)
|
tests/Feature/DependencyExtractionFeatureTest.php
|
||||||
|
tests/Unit/DependencyExtractionServiceTest.php
|
||||||
### Component Structure
|
|
||||||
```php
|
|
||||||
// app/Filament/Resources/InventoryItemResource.php (or dedicated Infolist)
|
|
||||||
Section::make('Dependencies')
|
|
||||||
->schema([
|
|
||||||
// Filter: Inbound / Outbound / All (default: All)
|
|
||||||
Forms\Components\Select::make('direction')
|
|
||||||
->options(['all' => 'All', 'inbound' => 'Inbound', 'outbound' => 'Outbound'])
|
|
||||||
->default('all')
|
|
||||||
->live(),
|
|
||||||
|
|
||||||
// Filter: Relationship Type (default: All)
|
|
||||||
Forms\Components\Select::make('relationship_type')
|
|
||||||
->options(['all' => 'All'] + /* RelationshipType enum options */ [])
|
|
||||||
->default('all')
|
|
||||||
->live(),
|
|
||||||
|
|
||||||
// Edges table (or custom Blade component)
|
|
||||||
ViewEntry::make('edges')
|
|
||||||
->view('filament.components.dependency-edges')
|
|
||||||
->state(fn (InventoryItem $record) =>
|
|
||||||
DependencyService::getEdges($record, request('direction', 'all'))
|
|
||||||
),
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Blade View: `resources/views/filament/components/dependency-edges.blade.php`
|
## Phase 0: Research (Output: research.md)
|
||||||
- **Zero-state**: If `$edges->isEmpty()`: "No dependencies found"
|
|
||||||
- **Edge rendering**:
|
|
||||||
- Group by `relationship_type` (collapsible groups or headings)
|
|
||||||
- Each edge: icon + target name + link (if resolvable) + "Missing" red badge (if target_type='missing') + tooltip (metadata.last_known_name)
|
|
||||||
- **Performance**: Load edges synchronously (≤50 per direction, fast query with indexes)
|
|
||||||
- **Privacy**: If display name unavailable, render masked identifier (e.g., `ID: abcd12…`), no cross-tenant lookups
|
|
||||||
|
|
||||||
### Filter Behavior
|
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).
|
||||||
- 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"
|
|
||||||
|
|
||||||
---
|
## Phase 1: Design (Outputs: data-model.md, contracts/*, quickstart.md)
|
||||||
|
|
||||||
## 4. Tests
|
- 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.
|
||||||
|
|
||||||
### Unit Tests
|
## Phase 2: Implementation Plan (MVP)
|
||||||
- `DependencyExtractionServiceTest.php`:
|
|
||||||
- `test_normalizes_references_deterministically()`: same raw input → same edges (order-independent)
|
|
||||||
- `test_respects_unique_key()`: re-extraction → no duplicate edges
|
|
||||||
- `test_handles_unsupported_references()`: logs warning, skips edge
|
|
||||||
|
|
||||||
- `InventoryLinkTest.php`:
|
1. UI filters: direction + relationship-type via querystring.
|
||||||
- `test_unique_constraint_enforced()`: duplicate insert → exception or upsert
|
2. Query: use DB filtering via `DependencyQueryService` optional `relationship_type`.
|
||||||
- `test_tenant_scoping()`: edges filtered by tenant_id
|
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.
|
||||||
|
|
||||||
### Feature Tests
|
## Complexity Tracking
|
||||||
- `DependencyExtractionFeatureTest.php`:
|
|
||||||
- `test_extraction_creates_expected_edges()`: fixture item → assert edges created (assigned_to, scoped_by)
|
|
||||||
- `test_extraction_marks_missing_targets()`: item references non-existent group → edge with target_type='missing'
|
|
||||||
- `test_extraction_respects_50_edge_limit()`: item with 60 refs → only 50 edges created
|
|
||||||
- `test_only_missing_prerequisites_scenario()`: item whose all targets are unresolved → only `missing` edges, UI renders badges
|
|
||||||
|
|
||||||
- `DependencyQueryServiceTest.php`:
|
None for MVP (no constitution violations).
|
||||||
- `test_get_outbound_edges_filters_by_tenant()`: cannot see other tenant's edges
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||||
- `test_get_inbound_edges_returns_correct_direction()`
|
|
||||||
|
|
||||||
### UI Smoke Tests
|
|
||||||
- `InventoryItemDependenciesTest.php`:
|
|
||||||
- `test_dependencies_section_renders()`: view page → see "Dependencies" section
|
|
||||||
- `test_filter_works()`: select "Inbound" → only inbound edges shown
|
|
||||||
- `test_zero_state_shown()`: item with no edges → "No dependencies found"
|
|
||||||
- `test_missing_badge_shown()`: edge with target_type='missing' → red badge visible
|
|
||||||
- `test_masks_identifier_when_name_unavailable()`: foundation object without display name → masked ID shown
|
|
||||||
|
|
||||||
### Security Tests
|
|
||||||
- `DependencyTenantIsolationTest.php`:
|
|
||||||
- `test_cannot_see_other_tenant_edges()`: tenant A user → cannot query tenant B edges
|
|
||||||
- `test_edges_scoped_by_tenant()`: all query methods enforce tenant_id filter
|
|
||||||
- `test_no_cross_tenant_enrichment()`: service never fetches/display names across tenants
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Sequence
|
|
||||||
|
|
||||||
1. **T001**: Define relationship taxonomy (enum + migration for reference data or config)
|
|
||||||
2. **T002**: Create `inventory_links` migration + model + factory
|
|
||||||
3. **T003**: Implement `DependencyExtractionService` (extraction logic + normalization)
|
|
||||||
4. **T004**: Integrate extraction into `InventorySyncService` (post-item-creation hook)
|
|
||||||
5. **T005**: Implement `DependencyQueryService` (getInbound/Outbound + tenant scoping)
|
|
||||||
6. **T006**: Add Filament "Dependencies" section to InventoryItem detail page
|
|
||||||
7. **T007**: Implement filter UI (direction select + live wire)
|
|
||||||
8. **T008**: Add Blade view for edge rendering (zero-state, missing badge, tooltip)
|
|
||||||
9. **T009**: Write unit tests (extraction, unique key, tenant scoping)
|
|
||||||
10. **T010**: Write feature tests (extraction, query, limits)
|
|
||||||
11. **T011**: Write UI smoke tests (section render, filter, zero-state)
|
|
||||||
12. **T012**: Run full test suite + Pint
|
|
||||||
|
|||||||
28
specs/042-inventory-dependencies-graph/quickstart.md
Normal file
28
specs/042-inventory-dependencies-graph/quickstart.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# 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[]`.
|
||||||
47
specs/042-inventory-dependencies-graph/research.md
Normal file
47
specs/042-inventory-dependencies-graph/research.md
Normal 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}`.
|
||||||
@ -1,48 +1,163 @@
|
|||||||
# Tasks: Inventory Dependencies Graph
|
# Tasks: Inventory Dependencies Graph (042)
|
||||||
|
|
||||||
## Schema & Data Model
|
**Input**: Design documents in `specs/042-inventory-dependencies-graph/` (plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md)
|
||||||
- [x] T001 Define relationship taxonomy (enum or config) with display labels, directionality, descriptions
|
|
||||||
- [x] T002 Create `inventory_links` migration with unique constraint + indexes
|
|
||||||
- [x] T003 Create `InventoryLink` model + factory
|
|
||||||
|
|
||||||
## Extraction Pipeline
|
**Notes**
|
||||||
- [x] T004 Implement `DependencyExtractionService` (normalize references, resolve targets, create edges)
|
- Tasks are grouped by user story so each story is independently testable.
|
||||||
- [x] T005 Add reference parsers for `assigned_to`, `scoped_by`, `targets`, `depends_on`
|
- Tests are included because this feature affects runtime behavior.
|
||||||
- [x] T006 Integrate extraction into `InventorySyncService` (post-item-creation hook)
|
- MVP constraints (direct only, limit-only, 50 per direction, warnings-on-run-record, no warnings tables) must remain enforced.
|
||||||
- [x] T007 Implement 50-edge-per-direction limit with priority sorting
|
|
||||||
|
|
||||||
## Query Services
|
## Phase 1: Setup (Shared)
|
||||||
- [x] T008 Implement `DependencyQueryService::getOutboundEdges(item, type?, limit=50)`
|
|
||||||
- [x] T009 Implement `DependencyQueryService::getInboundEdges(item, type?, limit=50)`
|
|
||||||
- [x] T010 Ensure tenant-scoping enforced at query builder level
|
|
||||||
|
|
||||||
## UI Components
|
**Purpose**: Ensure feature docs and scope constraints are locked before code changes.
|
||||||
- [x] T011 Add "Dependencies" section to `InventoryItemResource` ViewInventoryItem page
|
|
||||||
- [x] T012 Implement direction filter (single-select: all/inbound/outbound, default: all)
|
|
||||||
- [x] T013 Create Blade view `dependency-edges.blade.php` (zero-state, missing badge, tooltip)
|
|
||||||
- [x] T014 Add relationship-type grouping/collapsible sections
|
|
||||||
|
|
||||||
## Tests
|
- [ ] T001 Validate MVP constraints in `specs/042-inventory-dependencies-graph/plan.md` remain aligned with `specs/042-inventory-dependencies-graph/spec.md`
|
||||||
- [x] T015 Unit: `DependencyExtractionServiceTest` (determinism, unique key, unsupported refs)
|
- [ ] T002 Validate scope + NFR checkboxes in `specs/042-inventory-dependencies-graph/checklists/requirements.md` cover all accepted MVP constraints
|
||||||
- [x] T016 Unit: `InventoryLinkTest` (unique constraint, tenant scoping)
|
|
||||||
- [x] T017 Feature: extraction creates expected edges + handles missing targets
|
|
||||||
- [x] T018 Feature: extraction respects 50-edge limit
|
|
||||||
- [x] T019 Feature: `DependencyQueryService` filters by tenant + direction
|
|
||||||
- [x] T020 UI Smoke: dependencies section renders + filter works + zero-state shown
|
|
||||||
- [x] T021 Security: tenant isolation (cannot see other tenant edges)
|
|
||||||
|
|
||||||
## 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)
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
- [ ] 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)
|
**Purpose**: Storage + extraction + query services that all UI stories rely on.
|
||||||
- MVP is limit-only (no pagination/cursors).
|
|
||||||
- Show up to 50 edges per direction (up to 100 total for "all").
|
**Checkpoint**: After Phase 2, edges can be extracted and queried tenant-safely with limits.
|
||||||
- Unknown/unsupported shapes are warning-only; persist warnings on run record (`InventorySyncRun.error_context.warnings[]`).
|
|
||||||
- No new tables for warnings.
|
- [ ] 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@ -127,3 +127,62 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
$count = InventoryLink::query()->where('tenant_id', $tenant->getKey())->count();
|
$count = InventoryLink::query()->where('tenant_id', $tenant->getKey())->count();
|
||||||
expect($count)->toBe(50);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\InventoryLink;
|
use App\Models\InventoryLink;
|
||||||
|
use App\Models\Tenant;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
it('shows zero-state when no dependencies and shows missing badge when applicable', function () {
|
it('shows zero-state when no dependencies and shows missing badge when applicable', function () {
|
||||||
@ -27,10 +28,16 @@
|
|||||||
'target_type' => 'missing',
|
'target_type' => 'missing',
|
||||||
'target_id' => null,
|
'target_id' => null,
|
||||||
'relationship_type' => 'assigned_to',
|
'relationship_type' => 'assigned_to',
|
||||||
'metadata' => ['last_known_name' => null],
|
'metadata' => [
|
||||||
|
'last_known_name' => 'Ghost Target',
|
||||||
|
'raw_ref' => ['example' => 'ref'],
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->get($url)->assertOk()->assertSee('Missing');
|
$this->get($url)
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Missing')
|
||||||
|
->assertSee('Last known: Ghost Target');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('direction filter limits to outbound or inbound', function () {
|
it('direction filter limits to outbound or inbound', function () {
|
||||||
@ -109,3 +116,32 @@
|
|||||||
->assertSee('Scoped Target')
|
->assertSee('Scoped Target')
|
||||||
->assertDontSee('Assigned 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');
|
||||||
|
});
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Inventory\DependencyExtractionService;
|
use App\Services\Inventory\DependencyExtractionService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -29,8 +30,11 @@
|
|||||||
|
|
||||||
$svc = app(DependencyExtractionService::class);
|
$svc = app(DependencyExtractionService::class);
|
||||||
|
|
||||||
$svc->extractForPolicyData($item, $policyData);
|
$warnings1 = $svc->extractForPolicyData($item, $policyData);
|
||||||
$svc->extractForPolicyData($item, $policyData); // re-run, should be idempotent
|
$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();
|
$edges = InventoryLink::query()->where('tenant_id', $tenant->getKey())->get();
|
||||||
expect($edges)->toHaveCount(4);
|
expect($edges)->toHaveCount(4);
|
||||||
@ -43,7 +47,7 @@
|
|||||||
expect($tuples->count())->toBe(4);
|
expect($tuples->count())->toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles unsupported references by creating missing edges', function () {
|
it('handles unsupported references by recording warnings (no edges)', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
/** @var InventoryItem $item */
|
/** @var InventoryItem $item */
|
||||||
@ -59,11 +63,20 @@
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$svc = app(DependencyExtractionService::class);
|
Log::spy();
|
||||||
$svc->extractForPolicyData($item, $policyData);
|
|
||||||
|
|
||||||
$edge = InventoryLink::query()->first();
|
$svc = app(DependencyExtractionService::class);
|
||||||
expect($edge)->not->toBeNull();
|
|
||||||
expect($edge->target_type)->toBe('missing');
|
$warnings = $svc->extractForPolicyData($item, $policyData);
|
||||||
expect($edge->target_id)->toBeNull();
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user