merge: agent session work

This commit is contained in:
Ahmed Darrazi 2026-04-05 14:18:47 +02:00
commit 875b4ec9cc
44 changed files with 3786 additions and 487 deletions

View File

@ -131,6 +131,8 @@ ## Active Technologies
- PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced (175-workspace-governance-attention)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials (179-provider-truth-cleanup)
- PostgreSQL unchanged; no new table, column, or persisted artifact is introduced (179-provider-truth-cleanup)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack (177-inventory-coverage-truth)
- PostgreSQL; existing `inventory_items` rows and `operation_runs.context` / `operation_runs.summary_counts` JSONB are reused with no schema change (177-inventory-coverage-truth)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -150,8 +152,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 177-inventory-coverage-truth: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack
- 179-provider-truth-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials
- 175-workspace-governance-attention: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes
- 174-evidence-freshness-publication-trust: Added PHP 8.4, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -6,19 +6,20 @@
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -36,6 +37,7 @@
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use UnitEnum;
@ -56,6 +58,8 @@ class InventoryCoverage extends Page implements HasTable
protected string $view = 'filament.pages.inventory-coverage';
protected ?TenantCoverageTruth $cachedCoverageTruth = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
@ -67,7 +71,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full coverage matrix.');
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full tenant coverage report.');
}
public static function shouldRegisterNavigation(): bool
@ -110,9 +114,12 @@ protected function getHeaderWidgets(): array
public function table(Table $table): Table
{
return $table
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->searchable()
->searchPlaceholder('Search by policy type or label')
->defaultSort('label')
->searchPlaceholder('Search by type or label')
->defaultSort('follow_up_priority')
->defaultPaginationPageOption(50)
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->records(function (
@ -142,14 +149,16 @@ public function table(Table $table): Table
);
})
->columns([
TextColumn::make('type')
->label('Type')
->sortable()
->fontFamily(FontFamily::Mono)
->copyable()
->wrap(),
TextColumn::make('coverage_state')
->label('Coverage state')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventoryCoverageState))
->color(BadgeRenderer::color(BadgeDomain::InventoryCoverageState))
->icon(BadgeRenderer::icon(BadgeDomain::InventoryCoverageState))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventoryCoverageState))
->sortable(),
TextColumn::make('label')
->label('Label')
->label('Type')
->sortable()
->badge()
->formatStateUsing(function (?string $state, array $record): string {
@ -179,17 +188,29 @@ public function table(Table $table): Table
return $spec->iconColor ?? $spec->color;
})
->wrap(),
TextColumn::make('risk')
->label('Risk')
TextColumn::make('follow_up_guidance')
->label('Follow-up guidance')
->wrap()
->toggleable(),
TextColumn::make('observed_item_count')
->label('Observed items')
->numeric()
->sortable(),
TextColumn::make('category')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
->formatStateUsing(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->label)
->color(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->color)
->icon(fn (?string $state): ?string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->icon)
->iconColor(function (?string $state): ?string {
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state);
return $spec->iconColor ?? $spec->color;
})
->toggleable()
->wrap(),
TextColumn::make('restore')
->label('Restore')
->badge()
->state(fn (array $record): ?string => $record['restore'])
->formatStateUsing(function (?string $state): string {
return filled($state)
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
@ -213,20 +234,7 @@ public function table(Table $table): Table
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
return $spec->iconColor ?? $spec->color;
}),
TextColumn::make('category')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyCategory))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyCategory))
->toggleable()
->wrap(),
TextColumn::make('segment')
->label('Segment')
->badge()
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
})
->toggleable(),
IconColumn::make('dependencies')
->label('Dependencies')
@ -237,10 +245,31 @@ public function table(Table $table): Table
->falseColor('gray')
->alignCenter()
->toggleable(),
TextColumn::make('type')
->label('Type key')
->sortable()
->fontFamily(FontFamily::Mono)
->copyable()
->wrap()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('segment')
->label('Segment')
->badge()
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('risk')
->label('Risk')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk))
->toggleable(isToggledHiddenByDefault: true),
])
->filters($this->tableFilters())
->emptyStateHeading('No coverage entries match this view')
->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.')
->emptyStateHeading('No coverage rows match this report')
->emptyStateDescription('Clear the current search or filters to return to the full tenant coverage report.')
->emptyStateIcon('heroicon-o-funnel')
->emptyStateActions([
Action::make('clear_filters')
@ -261,6 +290,14 @@ public function table(Table $table): Table
protected function tableFilters(): array
{
$filters = [
SelectFilter::make('coverage_state')
->label('Coverage state')
->options([
'succeeded' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'succeeded')->label,
'failed' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label,
'skipped' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'skipped')->label,
'unknown' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'unknown')->label,
]),
SelectFilter::make('category')
->label('Category')
->options($this->categoryFilterOptions()),
@ -279,84 +316,36 @@ protected function tableFilters(): array
* @return Collection<string, array{
* __key: string,
* key: string,
* segment: string,
* type: string,
* segment: string,
* label: string,
* category: string,
* dependencies: bool,
* platform: ?string,
* coverage_state: string,
* follow_up_required: bool,
* follow_up_priority: int,
* follow_up_guidance: string,
* observed_item_count: int,
* basis_item_count: ?int,
* basis_error_code: ?string,
* restore: ?string,
* risk: string,
* source_order: int
* risk: ?string,
* dependencies: bool,
* is_basis_payload_backed: bool
* }>
*/
protected function coverageRows(): Collection
{
$resolver = app(CoverageCapabilitiesResolver::class);
$truth = $this->coverageTruth();
$supported = $this->mapCoverageRows(
rows: InventoryPolicyTypeMeta::supported(),
segment: 'policy',
sourceOrderOffset: 0,
resolver: $resolver,
);
if (! $truth instanceof TenantCoverageTruth) {
return collect();
}
return $supported->merge($this->mapCoverageRows(
rows: InventoryPolicyTypeMeta::foundations(),
segment: 'foundation',
sourceOrderOffset: $supported->count(),
resolver: $resolver,
));
}
/**
* @param array<int, array<string, mixed>> $rows
* @return Collection<string, array{
* __key: string,
* key: string,
* segment: string,
* type: string,
* label: string,
* category: string,
* dependencies: bool,
* restore: ?string,
* risk: string,
* source_order: int
* }>
*/
protected function mapCoverageRows(
array $rows,
string $segment,
int $sourceOrderOffset,
CoverageCapabilitiesResolver $resolver
): Collection {
return collect($rows)
->values()
->mapWithKeys(function (array $row, int $index) use ($resolver, $segment, $sourceOrderOffset): array {
$type = (string) ($row['type'] ?? '');
if ($type === '') {
return [];
}
$key = "{$segment}:{$type}";
$restore = $row['restore'] ?? null;
$risk = $row['risk'] ?? 'n/a';
return [
$key => [
'__key' => $key,
'key' => $key,
'segment' => $segment,
'type' => $type,
'label' => (string) ($row['label'] ?? $type),
'category' => (string) ($row['category'] ?? 'Other'),
'dependencies' => $segment === 'policy' && $resolver->supportsDependencies($type),
'restore' => is_string($restore) ? $restore : null,
'risk' => is_string($risk) ? $risk : 'n/a',
'source_order' => $sourceOrderOffset + $index,
],
];
});
return collect($truth->rows)
->mapWithKeys(static fn ($row): array => [
$row->key => $row->toArray(),
]);
}
/**
@ -367,6 +356,7 @@ protected function mapCoverageRows(
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
{
$normalizedSearch = Str::lower(trim((string) $search));
$coverageState = $filters['coverage_state']['value'] ?? null;
$category = $filters['category']['value'] ?? null;
$restore = $filters['restore']['value'] ?? null;
@ -380,6 +370,10 @@ function (Collection $rows) use ($normalizedSearch): Collection {
});
},
)
->when(
filled($coverageState),
fn (Collection $rows): Collection => $rows->where('coverage_state', (string) $coverageState),
)
->when(
filled($category),
fn (Collection $rows): Collection => $rows->where('category', (string) $category),
@ -396,22 +390,35 @@ function (Collection $rows) use ($normalizedSearch): Collection {
*/
protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
$sortColumn = in_array($sortColumn, ['type', 'label'], true) ? $sortColumn : null;
$sortColumn = in_array($sortColumn, ['type', 'label', 'observed_item_count', 'coverage_state', 'follow_up_priority'], true)
? $sortColumn
: null;
if ($sortColumn === null) {
return $rows->sortBy('source_order');
return $rows;
}
$records = $rows->all();
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
$comparison = strnatcasecmp(
(string) ($left[$sortColumn] ?? ''),
(string) ($right[$sortColumn] ?? ''),
);
$comparison = match ($sortColumn) {
'observed_item_count' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
'follow_up_priority' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
default => strnatcasecmp(
(string) ($left[$sortColumn] ?? ''),
(string) ($right[$sortColumn] ?? ''),
),
};
if ($comparison === 0 && $sortColumn === 'follow_up_priority') {
$comparison = ((int) ($right['observed_item_count'] ?? 0)) <=> ((int) ($left['observed_item_count'] ?? 0));
}
if ($comparison === 0) {
$comparison = ((int) ($left['source_order'] ?? 0)) <=> ((int) ($right['source_order'] ?? 0));
$comparison = strnatcasecmp(
(string) ($left['label'] ?? ''),
(string) ($right['label'] ?? ''),
);
}
return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
@ -468,4 +475,99 @@ protected function restoreFilterOptions(): array
})
->all();
}
/**
* @return array<string, mixed>
*/
public function coverageSummary(): array
{
$truth = $this->coverageTruth();
if (! $truth instanceof TenantCoverageTruth) {
return [];
}
return [
'supportedTypes' => $truth->supportedTypeCount,
'succeededTypes' => $truth->succeededTypeCount,
'followUpTypes' => $truth->followUpTypeCount,
'observedItems' => $truth->observedItemTotal,
'observedTypes' => $truth->observedTypeCount(),
'topFollowUpLabel' => $truth->topPriorityFollowUpRow()?->label,
'topFollowUpGuidance' => $truth->topPriorityFollowUpRow()?->followUpGuidance,
'hasCurrentCoverageResult' => $truth->hasCurrentCoverageResult,
];
}
/**
* @return array<string, mixed>
*/
public function basisRunSummary(): array
{
$truth = $this->coverageTruth();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $truth instanceof TenantCoverageTruth || ! $tenant instanceof Tenant) {
return [];
}
if (! $truth->basisRun instanceof OperationRun) {
return [
'title' => 'No current coverage basis',
'body' => $user instanceof User && $user->can(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant)
? 'Run Inventory Sync from Inventory Items to establish current tenant coverage truth.'
: 'A tenant operator with inventory sync permission must establish current tenant coverage truth.',
'badgeLabel' => null,
'badgeColor' => null,
'runUrl' => null,
'historyUrl' => null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
];
}
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
$canViewRun = $user instanceof User && Gate::forUser($user)->allows('view', $truth->basisRun);
return [
'title' => sprintf('Latest coverage-bearing sync completed %s.', $truth->basisCompletedAtLabel() ?? 'recently'),
'body' => $canViewRun
? 'Review the cited inventory sync to inspect provider or permission issues in detail.'
: 'The coverage basis is current, but your role cannot open the cited run detail.',
'badgeLabel' => $badge->label,
'badgeColor' => $badge->color,
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
];
}
protected function coverageTruth(): ?TenantCoverageTruth
{
if ($this->cachedCoverageTruth instanceof TenantCoverageTruth) {
return $this->cachedCoverageTruth;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return null;
}
$this->cachedCoverageTruth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
return $this->cachedCoverageTruth;
}
private function inventorySyncHistoryUrl(Tenant $tenant): string
{
return route('admin.operations.index', [
'tenant_id' => (int) $tenant->getKey(),
'tableFilters' => [
'type' => [
'value' => 'inventory_sync',
],
],
]);
}
}

View File

@ -16,6 +16,9 @@
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Inventory\InventoryCoverage;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperateHub\OperateHubShell;
@ -472,6 +475,21 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
}
}
$inventorySyncCoverageSection = static::inventorySyncCoverageSection($record);
if ($inventorySyncCoverageSection !== null) {
$builder->addSection(
$factory->viewSection(
id: 'inventory_sync_coverage',
kind: 'type_specific_detail',
title: 'Inventory sync coverage',
description: 'Per-type run results explain what this sync established without forcing operators into raw JSON first.',
view: 'filament.infolists.entries.inventory-coverage-truth',
viewData: $inventorySyncCoverageSection,
),
);
}
if (VerificationReportViewer::shouldRenderForRun($record)) {
$builder->addSection(
$factory->viewSection(
@ -1169,6 +1187,106 @@ private static function reconciliationPayload(OperationRun $record): array
return $reconciliation;
}
/**
* @return array{
* rows: list<array{
* type: string,
* label: string,
* segment: string,
* category: string,
* coverageState: string,
* followUpRequired: bool,
* followUpPriority: int,
* followUpGuidance: string,
* itemCount: int,
* errorCode: ?string
* }>,
* summary: array{
* totalTypes: int,
* succeededTypes: int,
* failedTypes: int,
* skippedTypes: int,
* followUpTypes: int,
* observedItems: int
* },
* runOutcomeLabel: string,
* runOutcomeColor: string,
* runOutcomeIcon: ?string
* }|null
*/
private static function inventorySyncCoverageSection(OperationRun $record): ?array
{
if ((string) $record->type !== 'inventory_sync') {
return null;
}
$coverage = $record->inventoryCoverage();
if (! $coverage instanceof InventoryCoverage) {
return null;
}
$rows = collect($coverage->rows())
->map(function (array $row): array {
$type = (string) ($row['type'] ?? '');
$meta = InventoryPolicyTypeMeta::metaFor($type);
$status = is_string($row['status'] ?? null) ? (string) $row['status'] : InventoryCoverage::StatusFailed;
$errorCode = is_string($row['error_code'] ?? null) ? (string) $row['error_code'] : null;
$itemCount = is_int($row['item_count'] ?? null) ? (int) $row['item_count'] : 0;
return [
'type' => $type,
'label' => is_string($meta['label'] ?? null) && $meta['label'] !== ''
? (string) $meta['label']
: $type,
'segment' => (string) ($row['segment'] ?? 'policy'),
'category' => is_string($meta['category'] ?? null) && $meta['category'] !== ''
? (string) $meta['category']
: 'Other',
'coverageState' => $status,
'followUpRequired' => $status !== InventoryCoverage::StatusSucceeded,
'followUpPriority' => TenantCoverageTruthResolver::followUpPriorityForState($status),
'followUpGuidance' => TenantCoverageTruthResolver::followUpGuidanceForState($status, $errorCode),
'itemCount' => $itemCount,
'errorCode' => $errorCode,
];
})
->sort(function (array $left, array $right): int {
$priority = ((int) ($left['followUpPriority'] ?? 0)) <=> ((int) ($right['followUpPriority'] ?? 0));
if ($priority !== 0) {
return $priority;
}
$items = ((int) ($right['itemCount'] ?? 0)) <=> ((int) ($left['itemCount'] ?? 0));
if ($items !== 0) {
return $items;
}
return strnatcasecmp((string) ($left['label'] ?? ''), (string) ($right['label'] ?? ''));
})
->values()
->all();
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
return [
'rows' => $rows,
'summary' => [
'totalTypes' => count($rows),
'succeededTypes' => count(array_filter($rows, static fn (array $row): bool => ($row['coverageState'] ?? null) === InventoryCoverage::StatusSucceeded)),
'failedTypes' => count(array_filter($rows, static fn (array $row): bool => ($row['coverageState'] ?? null) === InventoryCoverage::StatusFailed)),
'skippedTypes' => count(array_filter($rows, static fn (array $row): bool => ($row['coverageState'] ?? null) === InventoryCoverage::StatusSkipped)),
'followUpTypes' => count(array_filter($rows, static fn (array $row): bool => (bool) ($row['followUpRequired'] ?? false))),
'observedItems' => array_sum(array_map(static fn (array $row): int => (int) ($row['itemCount'] ?? 0), $rows)),
],
'runOutcomeLabel' => $outcomeSpec->label,
'runOutcomeColor' => $outcomeSpec->color,
'runOutcomeIcon' => $outcomeSpec->icon,
];
}
private static function formatDetailTimestamp(mixed $value): string
{
if (! $value instanceof \Illuminate\Support\Carbon) {

View File

@ -5,19 +5,20 @@
namespace App\Filament\Widgets\Inventory;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Inventory\InventoryKpiBadges;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\OperationRunLinks;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\HtmlString;
class InventoryKpiHeader extends StatsOverviewWidget
@ -28,12 +29,9 @@ class InventoryKpiHeader extends StatsOverviewWidget
protected int|string|array $columnSpan = 'full';
protected ?string $pollingInterval = null;
/**
* Inventory KPI aggregation source-of-truth:
* - `inventory_items.policy_type`
* - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`)
* - dependency capability via `CoverageCapabilitiesResolver`
*
* @return array<Stat>
*/
protected function getStats(): array
@ -43,126 +41,85 @@ protected function getStats(): array
if (! $tenant instanceof Tenant) {
return [
Stat::make('Total items', 0),
Stat::make('Coverage', '0%')->description('Select a tenant to load coverage.'),
Stat::make('Last inventory sync', '—')->description('Select a tenant to see the latest sync.'),
Stat::make('Covered types', '—')->description('Select a tenant to load coverage truth.'),
Stat::make('Need follow-up', '—')->description('Select a tenant to review follow-up types.'),
Stat::make('Coverage basis', '—')->description('Select a tenant to see the latest coverage basis.'),
Stat::make('Active ops', 0),
Stat::make('Inventory ops', 0)->description('Select a tenant to load dependency and risk counts.'),
];
}
$tenantId = (int) $tenant->getKey();
/** @var array<string, int> $countsByPolicyType */
$countsByPolicyType = InventoryItem::query()
->where('tenant_id', $tenantId)
->selectRaw('policy_type, COUNT(*) as aggregate')
->groupBy('policy_type')
->pluck('aggregate', 'policy_type')
->map(fn ($value): int => (int) $value)
->all();
$totalItems = array_sum($countsByPolicyType);
$restorableItems = 0;
$partialItems = 0;
$riskItems = 0;
foreach ($countsByPolicyType as $policyType => $count) {
if (InventoryPolicyTypeMeta::isRestorable($policyType)) {
$restorableItems += $count;
} elseif (InventoryPolicyTypeMeta::isPartial($policyType)) {
$partialItems += $count;
}
if (InventoryPolicyTypeMeta::isHighRisk($policyType)) {
$riskItems += $count;
}
}
$coveragePercent = $totalItems > 0
? (int) round(($restorableItems / $totalItems) * 100)
: 0;
$lastRun = OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'inventory_sync')
->where('status', OperationRunStatus::Completed->value)
->whereNotNull('completed_at')
->latest('completed_at')
->latest('id')
->first();
$lastInventorySyncTimeLabel = '—';
$lastInventorySyncStatusLabel = '—';
$lastInventorySyncStatusColor = 'gray';
$lastInventorySyncStatusIcon = 'heroicon-m-clock';
$lastInventorySyncViewUrl = null;
if ($lastRun instanceof OperationRun) {
$timestamp = $lastRun->completed_at ?? $lastRun->started_at ?? $lastRun->created_at;
if ($timestamp) {
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
}
$outcome = (string) ($lastRun->outcome ?? OperationRunOutcome::Pending->value);
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
$lastInventorySyncStatusLabel = $badge->label;
$lastInventorySyncStatusColor = $badge->color;
$lastInventorySyncStatusIcon = (string) ($badge->icon ?? 'heroicon-m-clock');
$lastInventorySyncViewUrl = route('admin.operations.view', ['run' => (int) $lastRun->getKey()]);
}
$badgeColor = $lastInventorySyncStatusColor;
$lastInventorySyncDescription = Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge :color="$badgeColor" size="sm">
{{ $statusLabel }}
</x-filament::badge>
@if ($viewUrl)
<x-filament::link :href="$viewUrl" size="sm">
Open operation
</x-filament::link>
@endif
</div>
BLADE, [
'badgeColor' => $badgeColor,
'statusLabel' => $lastInventorySyncStatusLabel,
'viewUrl' => $lastInventorySyncViewUrl,
]);
$truth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
$activeOps = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->where('tenant_id', (int) $tenant->getKey())
->active()
->count();
$inventoryOps = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync')
->active()
->count();
$resolver = app(CoverageCapabilitiesResolver::class);
$dependenciesItems = 0;
foreach ($countsByPolicyType as $policyType => $count) {
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
$dependenciesItems += $count;
}
}
return [
Stat::make('Total items', $totalItems),
Stat::make('Coverage', $coveragePercent.'%')
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))),
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel)
->description(new HtmlString($lastInventorySyncDescription)),
Stat::make('Active ops', $activeOps),
Stat::make('Inventory ops', $inventoryOps)
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))),
Stat::make('Total items', $truth->observedItemTotal)
->description(sprintf('Observed across %d supported types.', $truth->observedTypeCount())),
Stat::make('Covered types', sprintf('%d / %d', $truth->succeededTypeCount, $truth->supportedTypeCount))
->description(new HtmlString(InventoryKpiBadges::coverageBreakdown(
$truth->failedTypeCount,
$truth->skippedTypeCount,
$truth->unknownTypeCount,
))),
Stat::make('Need follow-up', $truth->followUpTypeCount)
->description(new HtmlString(InventoryKpiBadges::followUpSummary(
$truth->topPriorityFollowUpRow(),
$truth->observedItemTotal,
$truth->observedTypeCount(),
))),
$this->coverageBasisStat($truth, $tenant),
Stat::make('Active ops', $activeOps)
->description($inventoryOps > 0 ? 'A tenant inventory sync is queued or running.' : 'No inventory sync is currently active.'),
];
}
private function coverageBasisStat(TenantCoverageTruth $truth, Tenant $tenant): Stat
{
$user = auth()->user();
if (! $truth->basisRun instanceof OperationRun) {
return Stat::make('Coverage basis', 'No current result')
->description($user instanceof User && $user->can(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant)
? 'Run Inventory Sync from Inventory Items to establish current coverage truth.'
: 'A tenant operator with inventory sync permission must establish current coverage truth.');
}
$outcomeBadge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
$canViewRun = $user instanceof User && Gate::forUser($user)->allows('view', $truth->basisRun);
$description = Blade::render(<<<'BLADE'
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$badgeColor" size="sm">
{{ $statusLabel }}
</x-filament::badge>
@if ($canViewRun && $viewUrl)
<x-filament::link :href="$viewUrl" size="sm">
Open basis run
</x-filament::link>
@else
<span class="text-xs text-gray-600 dark:text-gray-300">
Latest run detail is not available with your current role.
</span>
@endif
</div>
BLADE, [
'badgeColor' => $outcomeBadge->color,
'statusLabel' => $outcomeBadge->label,
'canViewRun' => $canViewRun,
'viewUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
]);
return Stat::make('Coverage basis', $truth->basisCompletedAtLabel() ?? 'Completed')
->description(new HtmlString($description));
}
}

View File

@ -103,11 +103,15 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
$this->operationRun,
$tenant,
$context,
function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
$processedPolicyTypes[] = $policyType;
$coverageStatusByType[$policyType] = $success
? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed;
$coverageStatusByType[$policyType] = array_filter([
'status' => $success
? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed,
'item_count' => $itemCount,
'error_code' => $success ? null : $errorCode,
], static fn (mixed $value): bool => $value !== null);
if ($success) {
$successCount++;
@ -126,7 +130,10 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
continue;
}
$statusByType[$type] = InventoryCoverage::StatusSkipped;
$statusByType[$type] = [
'status' => InventoryCoverage::StatusSkipped,
'item_count' => 0,
];
}
foreach ($coverageStatusByType as $type => $status) {
@ -138,8 +145,16 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
}
if ((string) ($result['status'] ?? '') === 'skipped') {
$skippedErrorCode = is_string($result['error_codes'][0] ?? null)
? (string) $result['error_codes'][0]
: null;
foreach ($statusByType as $type => $status) {
$statusByType[$type] = InventoryCoverage::StatusSkipped;
$statusByType[$type] = array_filter([
'status' => InventoryCoverage::StatusSkipped,
'item_count' => 0,
'error_code' => $skippedErrorCode,
], static fn (mixed $value): bool => $value !== null);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
@ -253,11 +254,33 @@ public function setFinishedAtAttribute(mixed $value): void
$this->completed_at = $value;
}
public function inventoryCoverage(): ?InventoryCoverage
{
return InventoryCoverage::fromContext($this->context);
}
public function isGovernanceArtifactOperation(): bool
{
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
}
public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self
{
if ($tenantId <= 0) {
return null;
}
return static::query()
->where('tenant_id', $tenantId)
->where('type', 'inventory_sync')
->where('status', OperationRunStatus::Completed->value)
->whereNotNull('completed_at')
->latest('completed_at')
->latest('id')
->cursor()
->first(static fn (self $run): bool => $run->inventoryCoverage() instanceof InventoryCoverage);
}
public function supportsOperatorExplanation(): bool
{
return OperationCatalog::supportsOperatorExplanation((string) $this->type);

View File

@ -82,17 +82,24 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
continue;
}
$statusByType[$type] = InventoryCoverage::StatusSkipped;
$statusByType[$type] = [
'status' => InventoryCoverage::StatusSkipped,
'item_count' => 0,
];
}
$result = $this->executeSelection(
$operationRun,
$tenant,
$normalizedSelection,
function (string $policyType, bool $success, ?string $errorCode) use (&$statusByType): void {
$statusByType[$policyType] = $success
? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed;
function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use (&$statusByType): void {
$statusByType[$policyType] = array_filter([
'status' => $success
? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed,
'item_count' => $itemCount,
'error_code' => $success ? null : $errorCode,
], static fn (mixed $value): bool => $value !== null);
},
);
@ -126,10 +133,15 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$statusBy
$updatedContext = is_array($operationRun->context) ? $operationRun->context : [];
$coverageStatusByType = $statusByType;
$skippedErrorCode = is_string($errorCodes[0] ?? null) ? (string) $errorCodes[0] : null;
if ($status === 'skipped') {
foreach ($coverageStatusByType as $type => $coverageStatus) {
$coverageStatusByType[$type] = InventoryCoverage::StatusSkipped;
$coverageStatusByType[$type] = array_filter([
'status' => InventoryCoverage::StatusSkipped,
'item_count' => 0,
'error_code' => $skippedErrorCode,
], static fn (mixed $value): bool => $value !== null);
}
}
@ -176,7 +188,7 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$statusBy
* This method MUST NOT create or update legacy sync-run rows; OperationRun is canonical.
*
* @param array<string, mixed> $selectionPayload
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
* @param null|callable(string $policyType, bool $success, ?string $errorCode, int $itemCount): void $onPolicyTypeProcessed
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
*/
public function executeSelection(OperationRun $operationRun, Tenant $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed = null): array
@ -245,7 +257,7 @@ public function normalizeAndHashSelection(array $selectionPayload): array
/**
* @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
* @param null|callable(string $policyType, bool $success, ?string $errorCode, int $itemCount): void $onPolicyTypeProcessed
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
*/
private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): array
@ -256,6 +268,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
$errorCodes = [];
$hadErrors = false;
$warnings = [];
$observedByType = [];
try {
$connection = $this->resolveProviderConnection($tenant);
@ -277,7 +290,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
$hadErrors = true;
$errors++;
$errorCodes[] = 'unsupported_type';
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type');
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type', 0);
continue;
}
@ -293,7 +306,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
$errors++;
$errorCode = $this->mapGraphFailureToErrorCode($response);
$errorCodes[] = $errorCode;
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode);
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode, 0);
continue;
}
@ -313,6 +326,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
}
$observed++;
$observedByType[$policyType] = (int) ($observedByType[$policyType] ?? 0) + 1;
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true);
@ -384,7 +398,12 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
}
}
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null);
$onPolicyTypeProcessed && $onPolicyTypeProcessed(
$policyType,
true,
null,
(int) ($observedByType[$policyType] ?? 0),
);
}
return [

View File

@ -27,6 +27,7 @@ final class BadgeCatalog
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
BadgeDomain::InventoryCoverageState->value => Domains\InventoryCoverageStateBadge::class,
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,

View File

@ -18,6 +18,7 @@ enum BadgeDomain: string
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
case OperationRunStatus = 'operation_run_status';
case OperationRunOutcome = 'operation_run_outcome';
case InventoryCoverageState = 'inventory_coverage_state';
case BackupSetStatus = 'backup_set_status';
case RestoreRunStatus = 'restore_run_status';
case RestoreCheckSeverity = 'restore_check_severity';

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class InventoryCoverageStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'succeeded' => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -68,10 +68,56 @@ public function coveredTypes(): array
return array_values(array_unique($covered));
}
/**
* @return array<string, array{
* segment: 'policy'|'foundation',
* type: string,
* status: string,
* item_count?: int,
* error_code?: string|null
* }>
*/
public function rows(): array
{
$rows = [];
foreach ($this->policyTypes as $type => $meta) {
$rows[$type] = array_merge($meta, [
'segment' => 'policy',
'type' => $type,
]);
}
foreach ($this->foundationTypes as $type => $meta) {
$rows[$type] = array_merge($meta, [
'segment' => 'foundation',
'type' => $type,
]);
}
ksort($rows);
return $rows;
}
/**
* @return array{
* segment: 'policy'|'foundation',
* type: string,
* status: string,
* item_count?: int,
* error_code?: string|null
* }|null
*/
public function row(string $type): ?array
{
return $this->rows()[$type] ?? null;
}
/**
* Build the canonical `inventory.coverage.*` payload for OperationRun.context.
*
* @param array<string, string> $statusByType
* @param array<string, string|array{status: string, item_count?: int, error_code?: string|null}> $statusByType
* @param list<string> $foundationTypes
* @return array{policy_types: array<string, array{status: string}>, foundation_types: array<string, array{status: string}>}
*/
@ -88,14 +134,12 @@ public static function buildPayload(array $statusByType, array $foundationTypes)
continue;
}
$normalizedStatus = self::normalizeStatus($status);
$row = self::normalizeBuildRow($status);
if ($normalizedStatus === null) {
if ($row === null) {
continue;
}
$row = ['status' => $normalizedStatus];
if (array_key_exists($type, $foundationLookup)) {
$foundations[$type] = $row;
@ -114,6 +158,40 @@ public static function buildPayload(array $statusByType, array $foundationTypes)
];
}
/**
* @return array{status: string, item_count?: int, error_code?: string|null}|null
*/
private static function normalizeBuildRow(mixed $value): ?array
{
if (is_string($value)) {
$status = self::normalizeStatus($value);
return $status === null ? null : ['status' => $status];
}
if (! is_array($value)) {
return null;
}
$status = self::normalizeStatus($value['status'] ?? null);
if ($status === null) {
return null;
}
$row = ['status' => $status];
if (array_key_exists('item_count', $value) && is_int($value['item_count'])) {
$row['item_count'] = $value['item_count'];
}
if (array_key_exists('error_code', $value) && (is_string($value['error_code']) || $value['error_code'] === null)) {
$row['error_code'] = $value['error_code'];
}
return $row;
}
private static function normalizeStatus(mixed $status): ?string
{
if (! is_string($status)) {

View File

@ -8,39 +8,75 @@
class InventoryKpiBadges
{
public static function coverage(int $restorableCount, int $partialCount): string
public static function coverageBreakdown(int $failedCount, int $skippedCount, int $unknownCount): string
{
return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge color="success" size="sm">
Restorable {{ $restorableCount }}
</x-filament::badge>
if ($failedCount === 0 && $skippedCount === 0 && $unknownCount === 0) {
return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge color="success" size="sm">
No follow-up
</x-filament::badge>
</div>
BLADE);
}
<x-filament::badge color="warning" size="sm">
Partial {{ $partialCount }}
</x-filament::badge>
return Blade::render(<<<'BLADE'
<div class="flex flex-wrap items-center gap-2">
@if ($failedCount > 0)
<x-filament::badge color="danger" size="sm">
Failed {{ $failedCount }}
</x-filament::badge>
@endif
@if ($skippedCount > 0)
<x-filament::badge color="warning" size="sm">
Skipped {{ $skippedCount }}
</x-filament::badge>
@endif
@if ($unknownCount > 0)
<x-filament::badge color="gray" size="sm">
Unknown {{ $unknownCount }}
</x-filament::badge>
@endif
</div>
BLADE, [
'restorableCount' => $restorableCount,
'partialCount' => $partialCount,
'failedCount' => $failedCount,
'skippedCount' => $skippedCount,
'unknownCount' => $unknownCount,
]);
}
public static function inventoryOps(int $dependenciesCount, int $riskCount): string
public static function followUpSummary(?TenantCoverageTypeTruth $topPriorityRow, int $observedItemTotal, int $observedTypeCount): string
{
if (! $topPriorityRow instanceof TenantCoverageTypeTruth) {
return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge color="success" size="sm">
All covered
</x-filament::badge>
</div>
BLADE);
}
return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="gray" size="sm">
Dependencies {{ $dependenciesCount }}
{{ $topPriorityLabel }}
</x-filament::badge>
<x-filament::badge color="danger" size="sm">
Risk {{ $riskCount }}
<x-filament::badge color="info" size="sm">
Observed {{ $observedItemTotal }}
</x-filament::badge>
<span class="text-xs text-gray-600 dark:text-gray-300">
{{ $observedTypeCount }} supported types currently observed
</span>
</div>
BLADE, [
'dependenciesCount' => $dependenciesCount,
'riskCount' => $riskCount,
'topPriorityLabel' => $topPriorityRow->label,
'observedItemTotal' => $observedItemTotal,
'observedTypeCount' => $observedTypeCount,
]);
}
}

View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Support\Inventory;
use App\Models\OperationRun;
use InvalidArgumentException;
final readonly class TenantCoverageTruth
{
/**
* @param list<TenantCoverageTypeTruth> $rows
*/
public function __construct(
public int $tenantId,
public ?OperationRun $basisRun,
public bool $hasCurrentCoverageResult,
public int $supportedTypeCount,
public int $succeededTypeCount,
public int $failedTypeCount,
public int $skippedTypeCount,
public int $unknownTypeCount,
public int $followUpTypeCount,
public int $observedItemTotal,
public array $rows,
) {
if ($this->tenantId <= 0) {
throw new InvalidArgumentException('Tenant coverage truth requires a positive tenant id.');
}
if ($this->supportedTypeCount < 0 || $this->observedItemTotal < 0) {
throw new InvalidArgumentException('Tenant coverage truth counts must be zero or greater.');
}
}
public function basisRunId(): ?int
{
return $this->basisRun instanceof OperationRun
? (int) $this->basisRun->getKey()
: null;
}
public function basisRunOutcome(): ?string
{
return $this->basisRun instanceof OperationRun
? (string) $this->basisRun->outcome
: null;
}
public function basisCompletedAtLabel(): ?string
{
if (! $this->basisRun instanceof OperationRun) {
return null;
}
$timestamp = $this->basisRun->completed_at ?? $this->basisRun->started_at ?? $this->basisRun->created_at;
return $timestamp?->diffForHumans(['short' => true]);
}
public function topPriorityFollowUpRow(): ?TenantCoverageTypeTruth
{
foreach ($this->rows as $row) {
if ($row->followUpRequired) {
return $row;
}
}
return null;
}
public function observedTypeCount(): int
{
return count(array_filter(
$this->rows,
static fn (TenantCoverageTypeTruth $row): bool => $row->observedItemCount > 0,
));
}
/**
* @return list<TenantCoverageTypeTruth>
*/
public function followUpRows(): array
{
return array_values(array_filter(
$this->rows,
static fn (TenantCoverageTypeTruth $row): bool => $row->followUpRequired,
));
}
/**
* @return array{
* tenantId: int,
* basisRun: array{id: int, outcome: string, completedAt: string|null}|null,
* hasCurrentCoverageResult: bool,
* summary: array{
* supportedTypes: int,
* succeededTypes: int,
* failedTypes: int,
* skippedTypes: int,
* unknownTypes: int,
* followUpTypes: int,
* observedItems: int
* },
* rows: list<array<string, mixed>>
* }
*/
public function toArray(): array
{
return [
'tenantId' => $this->tenantId,
'basisRun' => $this->basisRun instanceof OperationRun
? [
'id' => (int) $this->basisRun->getKey(),
'outcome' => (string) $this->basisRun->outcome,
'completedAt' => $this->basisRun->completed_at?->toIso8601String(),
]
: null,
'hasCurrentCoverageResult' => $this->hasCurrentCoverageResult,
'summary' => [
'supportedTypes' => $this->supportedTypeCount,
'succeededTypes' => $this->succeededTypeCount,
'failedTypes' => $this->failedTypeCount,
'skippedTypes' => $this->skippedTypeCount,
'unknownTypes' => $this->unknownTypeCount,
'followUpTypes' => $this->followUpTypeCount,
'observedItems' => $this->observedItemTotal,
],
'rows' => array_map(
static fn (TenantCoverageTypeTruth $row): array => $row->toArray(),
$this->rows,
),
];
}
}

View File

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Support\Inventory;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use Illuminate\Support\Collection;
final class TenantCoverageTruthResolver
{
public function __construct(
private readonly CoverageCapabilitiesResolver $coverageCapabilities,
) {}
public function resolve(Tenant|int $tenant): TenantCoverageTruth
{
$tenantId = $tenant instanceof Tenant
? (int) $tenant->getKey()
: (int) $tenant;
$basisRun = OperationRun::latestCompletedCoverageBearingInventorySyncForTenant($tenantId);
$basisCoverage = $basisRun?->inventoryCoverage();
/** @var array<string, int> $countsByType */
$countsByType = InventoryItem::query()
->where('tenant_id', $tenantId)
->selectRaw('policy_type, COUNT(*) as aggregate')
->groupBy('policy_type')
->pluck('aggregate', 'policy_type')
->map(static fn (mixed $value): int => (int) $value)
->all();
$rows = $this->supportedTypes()
->map(function (array $meta) use ($basisCoverage, $countsByType): TenantCoverageTypeTruth {
$type = (string) $meta['type'];
$segment = (string) $meta['segment'];
$basisRow = $basisCoverage?->row($type);
$coverageState = is_string($basisRow['status'] ?? null)
? (string) $basisRow['status']
: TenantCoverageTypeTruth::StateUnknown;
$observedItemCount = (int) ($countsByType[$type] ?? 0);
$basisItemCount = is_int($basisRow['item_count'] ?? null)
? (int) $basisRow['item_count']
: null;
$basisErrorCode = is_string($basisRow['error_code'] ?? null)
? (string) $basisRow['error_code']
: null;
$followUpRequired = $coverageState !== TenantCoverageTypeTruth::StateSucceeded;
return new TenantCoverageTypeTruth(
key: sprintf('%s:%s', $segment, $type),
type: $type,
segment: $segment,
label: (string) ($meta['label'] ?? $type),
category: (string) ($meta['category'] ?? 'Other'),
platform: is_string($meta['platform'] ?? null) ? (string) $meta['platform'] : null,
coverageState: $coverageState,
followUpRequired: $followUpRequired,
followUpPriority: self::followUpPriorityForState($coverageState),
observedItemCount: $observedItemCount,
basisItemCount: $basisItemCount,
basisErrorCode: $basisErrorCode,
restoreMode: is_string($meta['restore'] ?? null) ? (string) $meta['restore'] : null,
riskLevel: is_string($meta['risk'] ?? null) ? (string) $meta['risk'] : null,
supportsDependencies: $segment === 'policy' && $this->coverageCapabilities->supportsDependencies($type),
followUpGuidance: self::followUpGuidanceForState($coverageState, $basisErrorCode),
isBasisPayloadBacked: $basisRow !== null,
);
})
->sort(function (TenantCoverageTypeTruth $left, TenantCoverageTypeTruth $right): int {
$priority = $left->followUpPriority <=> $right->followUpPriority;
if ($priority !== 0) {
return $priority;
}
$observed = $right->observedItemCount <=> $left->observedItemCount;
if ($observed !== 0) {
return $observed;
}
return strnatcasecmp($left->label, $right->label);
})
->values()
->all();
return new TenantCoverageTruth(
tenantId: $tenantId,
basisRun: $basisRun,
hasCurrentCoverageResult: $basisCoverage instanceof InventoryCoverage,
supportedTypeCount: count($rows),
succeededTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateSucceeded),
failedTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateFailed),
skippedTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateSkipped),
unknownTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateUnknown),
followUpTypeCount: count(array_filter(
$rows,
static fn (TenantCoverageTypeTruth $row): bool => $row->followUpRequired,
)),
observedItemTotal: array_sum($countsByType),
rows: $rows,
);
}
/**
* @return Collection<int, array{type: string, label: string, category: string, platform?: string|null, restore?: string|null, risk?: string|null, segment: 'policy'|'foundation'}>
*/
private function supportedTypes(): Collection
{
$supported = collect(InventoryPolicyTypeMeta::supported())
->filter(static fn (array $row): bool => is_string($row['type'] ?? null) && $row['type'] !== '')
->map(static fn (array $row): array => array_merge($row, ['segment' => 'policy']));
$foundations = collect(InventoryPolicyTypeMeta::foundations())
->filter(static fn (array $row): bool => is_string($row['type'] ?? null) && $row['type'] !== '')
->map(static fn (array $row): array => array_merge($row, ['segment' => 'foundation']));
return $supported
->merge($foundations)
->values();
}
public static function followUpPriorityForState(string $coverageState): int
{
return match ($coverageState) {
TenantCoverageTypeTruth::StateFailed => 0,
TenantCoverageTypeTruth::StateUnknown => 1,
TenantCoverageTypeTruth::StateSkipped => 2,
default => 3,
};
}
public static function followUpGuidanceForState(string $coverageState, ?string $basisErrorCode): string
{
return match (true) {
$coverageState === TenantCoverageTypeTruth::StateFailed && in_array($basisErrorCode, [
'graph_forbidden',
'provider_consent_missing',
'provider_permission_missing',
'provider_permission_denied',
], true) => 'Review provider consent or permissions, then rerun inventory sync.',
$coverageState === TenantCoverageTypeTruth::StateFailed && in_array($basisErrorCode, [
'graph_throttled',
'graph_transient',
'rate_limited',
'network_unreachable',
], true) => 'Retry inventory sync after the provider recovers.',
$coverageState === TenantCoverageTypeTruth::StateFailed => 'Review the latest inventory sync details before retrying.',
$coverageState === TenantCoverageTypeTruth::StateSkipped => 'Run inventory sync again with the required types selected.',
$coverageState === TenantCoverageTypeTruth::StateUnknown => 'No current basis result exists for this type. Run inventory sync to confirm coverage.',
default => 'No follow-up is currently required.',
};
}
/**
* @param list<TenantCoverageTypeTruth> $rows
*/
private function countRowsByState(array $rows, string $state): int
{
return count(array_filter(
$rows,
static fn (TenantCoverageTypeTruth $row): bool => $row->coverageState === $state,
));
}
}

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Support\Inventory;
use InvalidArgumentException;
final readonly class TenantCoverageTypeTruth
{
public const string StateSucceeded = InventoryCoverage::StatusSucceeded;
public const string StateFailed = InventoryCoverage::StatusFailed;
public const string StateSkipped = InventoryCoverage::StatusSkipped;
public const string StateUnknown = 'unknown';
public function __construct(
public string $key,
public string $type,
public string $segment,
public string $label,
public string $category,
public ?string $platform,
public string $coverageState,
public bool $followUpRequired,
public int $followUpPriority,
public int $observedItemCount,
public ?int $basisItemCount,
public ?string $basisErrorCode,
public ?string $restoreMode,
public ?string $riskLevel,
public bool $supportsDependencies,
public string $followUpGuidance,
public bool $isBasisPayloadBacked,
) {
if ($this->key === '' || $this->type === '' || $this->label === '') {
throw new InvalidArgumentException('Coverage truth rows require non-empty identity fields.');
}
}
/**
* @return array{
* __key: string,
* key: string,
* type: string,
* segment: string,
* label: string,
* category: string,
* platform: ?string,
* coverage_state: string,
* follow_up_required: bool,
* follow_up_priority: int,
* follow_up_guidance: string,
* observed_item_count: int,
* basis_item_count: ?int,
* basis_error_code: ?string,
* restore: ?string,
* risk: ?string,
* dependencies: bool,
* is_basis_payload_backed: bool
* }
*/
public function toArray(): array
{
return [
'__key' => $this->key,
'key' => $this->key,
'type' => $this->type,
'segment' => $this->segment,
'label' => $this->label,
'category' => $this->category,
'platform' => $this->platform,
'coverage_state' => $this->coverageState,
'follow_up_required' => $this->followUpRequired,
'follow_up_priority' => $this->followUpPriority,
'follow_up_guidance' => $this->followUpGuidance,
'observed_item_count' => $this->observedItemCount,
'basis_item_count' => $this->basisItemCount,
'basis_error_code' => $this->basisErrorCode,
'restore' => $this->restoreMode,
'risk' => $this->riskLevel,
'dependencies' => $this->supportsDependencies,
'is_basis_payload_backed' => $this->isBasisPayloadBacked,
];
}
}

View File

@ -0,0 +1,140 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain;
$rows = array_values(array_filter($rows ?? [], 'is_array'));
$summary = is_array($summary ?? null) ? $summary : [];
$followUpRows = array_values(array_filter($rows, static fn (array $row): bool => (bool) ($row['followUpRequired'] ?? false)));
$topFollowUp = $followUpRows[0] ?? null;
@endphp
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Types in run
</div>
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
{{ (int) ($summary['totalTypes'] ?? count($rows)) }}
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
Succeeded: {{ (int) ($summary['succeededTypes'] ?? 0) }}. Failed: {{ (int) ($summary['failedTypes'] ?? 0) }}. Skipped: {{ (int) ($summary['skippedTypes'] ?? 0) }}.
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Need follow-up
</div>
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
{{ (int) ($summary['followUpTypes'] ?? count($followUpRows)) }}
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
Execution outcome stays separate from the per-type results below.
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Observed items
</div>
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
{{ (int) ($summary['observedItems'] ?? 0) }}
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
Item counts show what this run observed for the listed types.
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Run outcome
</div>
<div class="mt-2">
<x-filament::badge :color="$runOutcomeColor ?? 'gray'" :icon="$runOutcomeIcon ?? null">
{{ $runOutcomeLabel ?? 'Unknown' }}
</x-filament::badge>
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
Coverage truth below explains which types created the follow-up.
</div>
</div>
</div>
@if ($topFollowUp !== null)
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
Highest-priority follow-up: {{ $topFollowUp['label'] ?? ($topFollowUp['type'] ?? 'Unknown type') }}. {{ $topFollowUp['followUpGuidance'] ?? 'Review the latest inventory sync details before retrying.' }}
</div>
@endif
<div class="space-y-3">
@foreach ($rows as $row)
@php
$typeSpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, $row['type'] ?? null);
$categorySpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $row['category'] ?? null);
$stateSpec = BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, $row['coverageState'] ?? null);
@endphp
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$typeSpec->color" :icon="$typeSpec->icon">
{{ $typeSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$stateSpec->color" :icon="$stateSpec->icon">
{{ $stateSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$categorySpec->color" :icon="$categorySpec->icon">
{{ $categorySpec->label }}
</x-filament::badge>
<x-filament::badge color="{{ ($row['segment'] ?? 'policy') === 'foundation' ? 'gray' : 'info' }}">
{{ ($row['segment'] ?? 'policy') === 'foundation' ? 'Foundation' : 'Policy' }}
</x-filament::badge>
</div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $row['label'] ?? ($row['type'] ?? 'Unknown type') }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $row['type'] ?? 'unknown' }}
</div>
</div>
<div class="rounded-xl bg-gray-50 px-3 py-2 text-right dark:bg-gray-950/60">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Observed items
</div>
<div class="mt-1 text-lg font-semibold text-gray-950 dark:text-white">
{{ (int) ($row['itemCount'] ?? 0) }}
</div>
</div>
</div>
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200">
{{ $row['followUpGuidance'] ?? 'No follow-up is currently required.' }}
</div>
@if (filled($row['errorCode'] ?? null))
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Reason code: {{ $row['errorCode'] }}
</div>
@endif
</div>
@endforeach
</div>
</div>

View File

@ -1,16 +1,121 @@
<x-filament-panels::page>
@php
$summary = $this->coverageSummary();
$basis = $this->basisRunSummary();
@endphp
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Searchable support matrix
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)]">
<div class="space-y-4">
<div class="space-y-2">
<div class="text-lg font-semibold text-gray-900 dark:text-white">
Tenant coverage truth
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
This report shows which supported inventory types are currently covered for the active tenant, which ones still need follow-up, and what the statement is based on.
</div>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Covered types
</div>
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
{{ $summary['succeededTypes'] ?? 0 }} / {{ $summary['supportedTypes'] ?? 0 }}
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
Current supported types with a successful basis result.
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Need follow-up
</div>
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
{{ $summary['followUpTypes'] ?? 0 }}
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
@if (filled($summary['topFollowUpLabel'] ?? null))
Highest-priority type: {{ $summary['topFollowUpLabel'] }}.
@else
No follow-up types are currently highlighted.
@endif
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Observed items
</div>
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
{{ $summary['observedItems'] ?? 0 }}
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
{{ $summary['observedTypes'] ?? 0 }} supported types currently have observed inventory rows.
</div>
</div>
</div>
@if (filled($summary['topFollowUpGuidance'] ?? null))
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
{{ $summary['topFollowUpGuidance'] }}
</div>
@endif
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Search by policy type or label, sort the primary columns, and filter the runtime-derived coverage matrix without leaving the tenant inventory workspace.
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="space-y-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Coverage basis
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Coverage rows combine supported policy types and foundations in a single read-only table so Segment and Dependencies stay easy to scan.
<div class="text-base font-semibold text-gray-950 dark:text-white">
{{ $basis['title'] ?? 'No current coverage basis' }}
</div>
</div>
@if (filled($basis['badgeLabel'] ?? null))
<x-filament::badge :color="$basis['badgeColor'] ?? 'gray'" size="sm">
{{ $basis['badgeLabel'] }}
</x-filament::badge>
@endif
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $basis['body'] ?? 'No current coverage basis is available.' }}
</div>
<div class="flex flex-wrap items-center gap-3">
@if (filled($basis['runUrl'] ?? null))
<x-filament::link :href="$basis['runUrl']" size="sm">
Open basis run
</x-filament::link>
@endif
@if (filled($basis['historyUrl'] ?? null))
<x-filament::link :href="$basis['historyUrl']" size="sm">
Inventory sync history
</x-filament::link>
@endif
@if (filled($basis['inventoryItemsUrl'] ?? null))
<x-filament::link :href="$basis['inventoryItemsUrl']" size="sm">
Open inventory items
</x-filament::link>
@endif
</div>
</div>
</div>
</div>
</x-filament::section>

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Spec 177 - Inventory Coverage Truth
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-05
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass completed after the draft was rewritten from the template scaffold.
- Follow-up inventory hardening ideas remain listed as future candidates without fixed spec numbers, since this repository already uses `179` for a different accepted spec.

View File

@ -0,0 +1,235 @@
openapi: 3.1.0
info:
title: Inventory Coverage Truth Surfaces
version: 0.1.0
description: |
Logical surface contract for Spec 177. These contracts describe the tenant-scoped
coverage truth that inventory surfaces must render, even when the delivered transport
is server-rendered Filament UI rather than a public JSON API.
paths:
/admin/inventory-items:
get:
summary: Inventory items list with truthful coverage summary
operationId: inventoryItemsCoverageSummary
responses:
'200':
description: Tenant-scoped inventory items list surface
content:
application/json:
schema:
type: object
additionalProperties: false
required:
- tenantId
- coverageSummary
properties:
tenantId:
type: integer
coverageSummary:
$ref: '#/components/schemas/CoverageSummary'
'403':
description: Member lacks capability for a linked follow-up action
'404':
description: User is not entitled to the tenant or workspace scope
/admin/coverage:
get:
summary: Tenant coverage truth report
operationId: inventoryCoverageReport
responses:
'200':
description: Tenant-scoped coverage report surface
content:
application/json:
schema:
type: object
additionalProperties: false
required:
- tenantId
- coverageSummary
- rows
properties:
tenantId:
type: integer
coverageSummary:
$ref: '#/components/schemas/CoverageSummary'
rows:
type: array
items:
$ref: '#/components/schemas/CoverageRow'
'403':
description: Member lacks capability for a linked follow-up action
'404':
description: User is not entitled to the tenant or workspace scope
/admin/operations/{run}:
get:
summary: Canonical inventory-sync run detail with per-type coverage section
operationId: inventorySyncRunDetail
parameters:
- in: path
name: run
required: true
schema:
type: integer
responses:
'200':
description: Canonical operation run detail surface
content:
application/json:
schema:
type: object
additionalProperties: false
required:
- id
- type
- status
- outcome
properties:
id:
type: integer
type:
type: string
enum:
- inventory_sync
status:
type: string
enum:
- queued
- running
- completed
outcome:
type: string
enum:
- pending
- succeeded
- partially_succeeded
- failed
- blocked
inventoryCoverageSection:
oneOf:
- $ref: '#/components/schemas/InventoryCoverageRunSection'
- type: 'null'
'403':
description: In-scope member lacks the run capability required for this operation
'404':
description: User is not entitled to the workspace or tenant scope for this run
components:
schemas:
BasisRun:
type: object
additionalProperties: false
required:
- id
- outcome
- completedAt
properties:
id:
type: integer
outcome:
type: string
enum:
- succeeded
- partially_succeeded
- failed
- blocked
completedAt:
type: string
format: date-time
CoverageSummary:
type: object
additionalProperties: false
required:
- hasCurrentCoverageResult
- supportedTypes
- succeededTypes
- failedTypes
- skippedTypes
- unknownTypes
- followUpTypes
- observedItems
properties:
hasCurrentCoverageResult:
type: boolean
basisRun:
oneOf:
- $ref: '#/components/schemas/BasisRun'
- type: 'null'
supportedTypes:
type: integer
succeededTypes:
type: integer
failedTypes:
type: integer
skippedTypes:
type: integer
unknownTypes:
type: integer
followUpTypes:
type: integer
observedItems:
type: integer
CoverageRow:
type: object
additionalProperties: false
required:
- type
- segment
- label
- category
- coverageState
- followUpRequired
- observedItemCount
- supportsDependencies
properties:
type:
type: string
segment:
type: string
enum:
- policy
- foundation
label:
type: string
category:
type: string
platform:
type:
- string
- 'null'
coverageState:
type: string
enum:
- succeeded
- failed
- skipped
- unknown
followUpRequired:
type: boolean
observedItemCount:
type: integer
basisErrorCode:
type:
- string
- 'null'
restoreMode:
type:
- string
- 'null'
riskLevel:
type:
- string
- 'null'
supportsDependencies:
type: boolean
InventoryCoverageRunSection:
type: object
additionalProperties: false
required:
- basisRun
- rows
properties:
basisRun:
$ref: '#/components/schemas/BasisRun'
rows:
type: array
items:
$ref: '#/components/schemas/CoverageRow'

View File

@ -0,0 +1,187 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/contracts/tenant-coverage-truth.schema.json",
"title": "Tenant Coverage Truth",
"type": "object",
"additionalProperties": false,
"required": [
"tenantId",
"hasCurrentCoverageResult",
"summary",
"rows"
],
"properties": {
"tenantId": {
"type": "integer"
},
"basisRun": {
"oneOf": [
{
"$ref": "#/$defs/basisRun"
},
{
"type": "null"
}
]
},
"hasCurrentCoverageResult": {
"type": "boolean"
},
"summary": {
"$ref": "#/$defs/summary"
},
"rows": {
"type": "array",
"items": {
"$ref": "#/$defs/row"
}
}
},
"$defs": {
"basisRun": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"outcome",
"completedAt"
],
"properties": {
"id": {
"type": "integer"
},
"outcome": {
"type": "string",
"enum": [
"succeeded",
"partially_succeeded",
"failed",
"blocked"
]
},
"completedAt": {
"type": "string",
"format": "date-time"
}
}
},
"summary": {
"type": "object",
"additionalProperties": false,
"required": [
"supportedTypes",
"succeededTypes",
"failedTypes",
"skippedTypes",
"unknownTypes",
"followUpTypes",
"observedItems"
],
"properties": {
"supportedTypes": {
"type": "integer",
"minimum": 0
},
"succeededTypes": {
"type": "integer",
"minimum": 0
},
"failedTypes": {
"type": "integer",
"minimum": 0
},
"skippedTypes": {
"type": "integer",
"minimum": 0
},
"unknownTypes": {
"type": "integer",
"minimum": 0
},
"followUpTypes": {
"type": "integer",
"minimum": 0
},
"observedItems": {
"type": "integer",
"minimum": 0
}
}
},
"row": {
"type": "object",
"additionalProperties": false,
"required": [
"type",
"segment",
"label",
"category",
"coverageState",
"followUpRequired",
"observedItemCount",
"supportsDependencies"
],
"properties": {
"type": {
"type": "string"
},
"segment": {
"type": "string",
"enum": [
"policy",
"foundation"
]
},
"label": {
"type": "string"
},
"category": {
"type": "string"
},
"platform": {
"type": [
"string",
"null"
]
},
"coverageState": {
"type": "string",
"enum": [
"succeeded",
"failed",
"skipped",
"unknown"
]
},
"followUpRequired": {
"type": "boolean"
},
"observedItemCount": {
"type": "integer",
"minimum": 0
},
"basisErrorCode": {
"type": [
"string",
"null"
]
},
"restoreMode": {
"type": [
"string",
"null"
]
},
"riskLevel": {
"type": [
"string",
"null"
]
},
"supportsDependencies": {
"type": "boolean"
}
}
}
}
}

View File

@ -0,0 +1,163 @@
# Phase 1 Data Model: Inventory Coverage Truth (177)
## Existing Persisted Truth
### `OperationRun` as coverage basis
Represents the canonical execution record for inventory syncs and remains the source of per-type sync truth.
**Relevant existing fields**
- `workspace_id`
- `tenant_id`
- `type`
- `status`
- `outcome`
- `summary_counts`
- `failure_summary`
- `context`
- `started_at`
- `completed_at`
**Relevant existing inventory context shape**
- `context.inventory.coverage.policy_types`
- `context.inventory.coverage.foundation_types`
- each entry stores at minimum `status`
- optional fields already supported by `InventoryCoverage` normalization:
- `item_count`
- `error_code`
**Existing invariant**
- `InventoryCoverage::fromContext()` is the canonical parser for run coverage payload.
### `InventoryItem`
Represents last observed tenant inventory rows and remains the source of current observed-item counts.
**Relevant existing fields**
- `workspace_id`
- `tenant_id`
- `policy_type`
- `external_id`
- `display_name`
- `category`
- `platform`
- `meta_jsonb`
- `last_seen_at`
- `last_seen_operation_run_id`
**Existing invariant**
- Observed rows prove last observation only. They do not by themselves prove current tenant coverage completeness.
### `InventoryPolicyTypeMeta` + capability metadata
Represents product support and capability reference for supported and foundation types.
**Relevant existing fields and derived attributes**
- `type`
- `label`
- `category`
- `platform`
- `restore`
- `risk`
- foundation flag
- dependency support from `CoverageCapabilitiesResolver`
**Existing invariant**
- Capability metadata is product support truth, not tenant coverage truth.
## New Derived Runtime Contract
### `TenantCoverageTruth`
Derived runtime contract that answers the operator question: which supported types are currently covered for this tenant, which types need follow-up, and which run establishes that statement.
**Proposed fields**
- `tenant_id`
- `basis_run_id` nullable
- `basis_run_outcome` nullable
- `basis_completed_at` nullable
- `has_current_coverage_result` boolean
- `supported_type_count`
- `succeeded_type_count`
- `failed_type_count`
- `skipped_type_count`
- `unknown_type_count`
- `follow_up_type_count`
- `observed_item_total`
- `rows` list of `TenantCoverageTypeTruth`
**Derived invariants**
- Exactly one row exists for each supported policy type and foundation type currently in the product support catalog.
- `follow_up_type_count = failed + skipped + unknown`.
- `has_current_coverage_result` is true only when a completed inventory-sync basis run with parseable payload exists.
- The basis run is chosen independently from current item counts.
### `TenantCoverageTypeTruth`
Derived row contract for one supported type.
**Proposed fields**
- `type`
- `segment` (`policy` or `foundation`)
- `label`
- `category`
- `platform` nullable
- `coverage_state` (`succeeded`, `failed`, `skipped`, `unknown`)
- `follow_up_required` boolean
- `observed_item_count`
- `basis_error_code` nullable
- `restore_mode` nullable
- `risk_level` nullable
- `supports_dependencies` boolean
**Derived invariants**
- `follow_up_required` is true for `failed`, `skipped`, and `unknown`; false only for `succeeded`.
- `observed_item_count > 0` does not change `coverage_state`.
- `coverage_state = unknown` when the type is supported but absent from the basis run payload.
- `basis_error_code` is allowed only for non-succeeded payload-backed states.
## Derived State Family
### Coverage state family
This feature introduces one derived state family for tenant coverage rows:
- `Succeeded`
- the basis run reported the type as successfully processed
- `Failed`
- the basis run reported the type as attempted and failed
- `Skipped`
- the basis run reported the type as intentionally skipped or not processed during that run
- `Unknown`
- no current coverage result exists for the supported type in the basis run
**Behavioral consequence**
- `Failed`, `Skipped`, and `Unknown` all suppress calm claims and increment follow-up counts.
- `Unknown` is derived, not persisted.
## Relationships
- One `TenantCoverageTruth` resolves for one tenant at a time.
- One `TenantCoverageTruth` may reference zero or one basis `OperationRun`.
- One `TenantCoverageTruth` contains one row per supported product type from `InventoryPolicyTypeMeta::supported()` and `InventoryPolicyTypeMeta::foundations()`.
- Each `TenantCoverageTypeTruth` joins one supported type to zero or one payload-backed status from the basis run and zero or more `InventoryItem` rows from the current tenant observation set.
## Selection Rules
### Basis run selection
- candidate runs are `OperationRun` rows where:
- `tenant_id` matches the selected tenant
- `type = inventory_sync`
- `status = completed`
- candidates are ordered by:
- `completed_at DESC`
- `id DESC`
- the selected basis run is the first candidate whose `context.inventory.coverage` payload can be parsed by `InventoryCoverage::fromContext()`
- if no candidate qualifies, the tenant has no current coverage basis run
### Unknown derivation
- if a supported type is absent from both `policy_types` and `foundation_types` in the selected basis payload, the type is `Unknown`
- absence from the basis payload is not converted into `Skipped`
- item presence from older runs does not upgrade `Unknown`
## Validation Rules
- No schema migration is required.
- No new persisted state is introduced.
- Coverage rows must remain tenant-scoped.
- Capability metadata must not alter the derived coverage state.
- Surfaces may cite the basis run only when they can do so without violating authorization.

View File

@ -0,0 +1,285 @@
# Implementation Plan: Inventory Coverage Truth
**Branch**: `feat/177-inventory-coverage-truth` | **Date**: 2026-04-05 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/177-inventory-coverage-truth/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/177-inventory-coverage-truth/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Correct inventory coverage semantics by deriving tenant coverage truth from the latest completed inventory-sync run that contains usable per-type coverage payload, joining that run truth with current inventory-item counts and existing supported-type metadata, replacing the misleading coverage percentage in the inventory KPI header with operator-readable counts, refocusing the coverage page on tenant follow-up, and exposing a human-readable per-type coverage section on inventory-sync run detail. The implementation stays fully derived, introduces no new persistence, keeps capability and support metadata secondary, and preserves existing sync execution, authorization, and Ops-UX behavior.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack
**Storage**: PostgreSQL; existing `inventory_items` rows and `operation_runs.context` / `operation_runs.summary_counts` JSONB are reused with no schema change
**Testing**: Pest 4 unit and feature tests, including Filament or Livewire page coverage, run through Laravel Sail
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment for staging and production
**Project Type**: web application
**Performance Goals**: DB-only render path for inventory summary and coverage surfaces; no render-time Graph calls; one tenant-scoped basis-run lookup plus grouped item counts per render; default-visible inventory surfaces expose covered versus follow-up types, basis-run context, and next-step guidance without raw JSON inspection
**Constraints**: Derived-only implementation; no new coverage table; no inventory-sync backend rewrite; no new Graph calls; no new destructive actions; capability metadata stays secondary; unauthorized run drill-through must degrade safely
**Scale/Scope**: One tenant at a time across three primary surfaces: inventory KPI header on the items list, the inventory coverage page, and canonical inventory-sync run detail; dozens of supported and foundation types rather than unbounded row counts
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | Coverage remains derived from current inventory observation and canonical inventory-sync runs; no snapshot or backup truth is introduced. |
| Read/write separation | PASS | PASS | The feature changes read-time surfaces only. Existing `Run Inventory Sync` semantics remain unchanged. |
| Graph contract path | PASS | PASS | No new Graph call path or contract registry entry is introduced. |
| Deterministic capabilities | PASS | PASS | Capability and support metadata remain derived from existing policy-type meta and capability resolvers. |
| Workspace + tenant isolation | PASS | PASS | All coverage truth stays tenant-scoped; canonical operations drill-through remains tenant-safe and entitlement-checked. |
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`; members lacking the run capability remain `403` on run detail; coverage surfaces must degrade safely instead of exposing broken links. |
| Run observability / Ops-UX | PASS | PASS | Existing `inventory_sync` `OperationRun` remains canonical. No new run type or new feedback surface is introduced. |
| Ops-UX lifecycle / summary counts | PASS | PASS | `OperationRunService` remains the only transition path; `summary_counts` remain canonical and numeric-only. The plan only adds read-time rendering. |
| Data minimization | PASS | PASS | Existing whitelisted inventory metadata and sanitized run context are reused; no new persisted payload surface is added. |
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED DERIVED CONTRACT | PASS WITH JUSTIFIED DERIVED CONTRACT | One narrow runtime contract plus resolver is justified because current truth is split across run context, item counts, and supported-type metadata, and three operator surfaces already need the same synthesis. |
| Persisted truth / behavioral state | PASS | PASS | `Unknown` is derived presentation truth, not persisted domain state. No new tables or durable artifacts are introduced. |
| UI semantics / few layers | PASS | PASS | The design replaces a misleading KPI and capability-first report with one direct tenant-coverage read model instead of adding a wider semantic framework. |
| Badge semantics (BADGE-001) | PASS | PASS | Coverage state badges stay centralized and test-covered. No page-local badge language is introduced. |
| Filament-native UI / Action Surface Contract | PASS | PASS | Existing Filament tables, stats, sections, and enterprise-detail views are reused. The existing derived-row exception on `InventoryCoverage` remains the only UI exception. |
| Filament UX-001 | PASS | PASS | The coverage page remains a searchable, filterable table surface with one clear empty-state CTA; run detail remains a view-style operational page; no create or edit layout changes are needed. |
| List-surface review checklist reference | PASS | PASS | The inventory items list and inventory coverage page are governed by `docs/product/standards/list-surface-review-checklist.md`, which must be applied before sign-off. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The plan stays inside the existing Filament v5 + Livewire v4 stack. No legacy APIs are introduced. |
| Provider registration location | PASS | PASS | No panel or provider registration change is involved; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No globally searchable resource is added or modified. |
| Destructive action safety | PASS | PASS | No new destructive action is introduced. Existing sync start action remains non-destructive and capability-gated. |
| Asset strategy | PASS | PASS | No asset registration or `filament:assets` deployment change is required. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The design adds focused resolver, surface, and RBAC-safe continuity tests that protect operator-visible business truth. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/177-inventory-coverage-truth/research.md`.
Key decisions:
- Define the relevant coverage basis as the latest completed `inventory_sync` run for the tenant that contains a parseable `context.inventory.coverage` payload, regardless of overall run outcome, so `Failed` and `Skipped` remain visible when the run produced per-type truth.
- Keep tenant coverage fully derived from three existing sources: `OperationRun.context.inventory.coverage`, current `InventoryItem` counts, and existing supported-type metadata plus capability metadata.
- Introduce one narrow runtime contract and resolver in `App\Support\Inventory` rather than extending the low-level `InventoryCoverage` parser or adding request-scoped caching infrastructure.
- Replace the KPI percentage with count-based summary facts, since the spec explicitly prioritizes semantically clear counts over percentage language.
- Keep the coverage page as one truth-first report with summary + per-type table, and move capability metadata into secondary columns or reference treatment rather than preserving the current capability-first matrix.
- Add a dedicated human-readable per-type coverage section to the existing enterprise-detail run page instead of creating a new operations screen.
- Defer any first-class `stale` or freshness state family to later health hardening work; this slice surfaces the basis timestamp clearly without adding another semantic layer.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/177-inventory-coverage-truth/`:
- `data-model.md`: existing persisted truths plus the new derived tenant coverage contract
- `contracts/inventory-coverage-truth.openapi.yaml`: logical surface contract for the inventory items summary, coverage page, and run detail continuity
- `contracts/tenant-coverage-truth.schema.json`: schema for the derived tenant coverage truth contract consumed by the UI
- `quickstart.md`: focused implementation and verification workflow
Design decisions:
- The new runtime contract is summary-first and derived; it does not become a new persisted truth source.
- `InventoryCoverage` remains the low-level parser for `OperationRun` context; a sibling resolver assembles tenant coverage truth by joining the parsed payload with item counts and metadata.
- The inventory KPI header will shift from `Coverage %` to count-based coverage signals and explicit basis-run context.
- The `InventoryCoverage` page will reuse its existing table surface but reorder the page around tenant coverage truth and follow-up, with capability metadata kept clearly secondary.
- Inventory-sync run detail will gain one enterprise-detail section backed by a custom Blade view under the existing `filament.infolists.entries.*` convention.
- No new request-scoped caching or cross-surface aggregate infrastructure is introduced in this slice; one resolver is sufficient.
## Project Structure
### Documentation (this feature)
```text
specs/177-inventory-coverage-truth/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── inventory-coverage-truth.openapi.yaml
│ └── tenant-coverage-truth.schema.json
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── InventoryCoverage.php
│ ├── Resources/
│ │ ├── InventoryItemResource/
│ │ │ └── Pages/
│ │ │ └── ListInventoryItems.php
│ │ └── OperationRunResource.php
│ ├── Pages/
│ │ └── Operations/
│ │ └── TenantlessOperationRunViewer.php
│ └── Widgets/
│ └── Inventory/
│ └── InventoryKpiHeader.php
├── Models/
│ ├── InventoryItem.php
│ └── OperationRun.php
└── Support/
└── Inventory/
├── CoverageCapabilitiesResolver.php
├── InventoryCoverage.php
├── InventoryPolicyTypeMeta.php
├── TenantCoverageTruth.php
└── TenantCoverageTruthResolver.php
resources/
└── views/
└── filament/
├── pages/
│ └── inventory-coverage.blade.php
└── infolists/
└── entries/
└── inventory-coverage-truth.blade.php
tests/
├── Feature/
│ ├── Filament/
│ │ ├── InventoryCoverageAdminTenantParityTest.php
│ │ ├── InventoryCoverageTableTest.php
│ │ ├── InventoryItemResourceTest.php
│ │ ├── InventoryPagesTest.php
│ │ └── OperationRunEnterpriseDetailPageTest.php
│ ├── Inventory/
│ │ ├── InventorySyncServiceTest.php
│ │ ├── InventorySyncStartSurfaceTest.php
│ │ └── RunInventorySyncJobTest.php
│ ├── Operations/
│ │ └── TenantlessOperationRunViewerTest.php
│ └── Rbac/
│ └── InventoryItemResourceAuthorizationTest.php
└── Unit/
└── Support/
└── Inventory/
└── TenantCoverageTruthResolverTest.php
```
**Structure Decision**: Keep the existing Laravel monolith layout. Add one narrow derived coverage contract plus resolver under `app/Support/Inventory`, update the three existing operator surfaces, add one custom enterprise-detail view, and extend the current feature and unit tests instead of creating new base directories or a broader presentation framework.
## Implementation Strategy
### Phase A — Introduce the Derived Coverage Contract
**Goal**: Add one explicit runtime contract that represents tenant coverage truth without changing persistence.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Support/Inventory/TenantCoverageTruth.php` | Add a readonly runtime contract representing basis-run metadata, summary counts, and per-type tenant coverage rows |
| A.2 | `app/Support/Inventory/TenantCoverageTruthResolver.php` | Add the resolver that selects the latest completed coverage-bearing inventory-sync run, parses `InventoryCoverage`, joins current item counts, and synthesizes follow-up classification |
| A.3 | `app/Support/Inventory/InventoryCoverage.php` | Keep the low-level parser focused on run payload normalization; extend only if a small helper is needed for row access, not for tenant-level joining |
### Phase B — Refactor the Inventory KPI Header
**Goal**: Remove the misleading percentage and replace it with count-based tenant coverage truth.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Filament/Widgets/Inventory/InventoryKpiHeader.php` | Replace `Coverage %` with count-based coverage stats such as succeeded types and types needing follow-up, plus explicit basis-run or no-sync context while keeping any restore or compare metadata clearly separate from coverage truth |
| B.2 | `tests/Feature/Filament/InventoryPagesTest.php` and `tests/Feature/Filament/InventoryItemResourceTest.php` | Preserve the existing inventory list inspect and sync-start affordances while asserting the summary surface no longer implies completeness from restorable-item share |
### Phase C — Recenter the Coverage Page Around Tenant Truth
**Goal**: Turn the current capability-first page into a tenant coverage report without removing support metadata entirely.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Filament/Pages/InventoryCoverage.php` | Replace the current static capability row builder with rows sourced from `TenantCoverageTruthResolver`; lead with coverage-state, basis-run, observed-item, follow-up columns, and deterministic follow-up priority ordering |
| C.2 | `resources/views/filament/pages/inventory-coverage.blade.php` | Add or adjust the summary zone so the page cites the basis run, last sync time, explicit no-sync fallback, provider or permission follow-up guidance, and follow-up summary before the table |
| C.3 | `tests/Feature/Filament/InventoryCoverageTableTest.php` and `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php` | Prove the page is tenant-coverage-first, keeps admin and tenant context parity, covers deterministic follow-up priority and no-basis-run messaging, and retains support, restore, and compare metadata only as secondary treatment |
### Phase D — Add Human-Readable Per-Type Results To Run Detail
**Goal**: Make inventory-sync per-type truth readable without raw JSON inspection.
| Step | File | Change |
|------|------|--------|
| D.1 | `app/Filament/Resources/OperationRunResource.php` | For `inventory_sync` runs, add a dedicated enterprise-detail section that renders per-type coverage truth from `InventoryCoverage::fromContext()` |
| D.2 | `resources/views/filament/infolists/entries/inventory-coverage-truth.blade.php` | Add the custom section view used by the enterprise-detail builder for per-type status, item counts, follow-up priority, and provider or permission follow-up cues |
| D.3 | `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` and `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` | Assert that inventory-sync runs render the new coverage section and that execution outcome stays distinct from per-type coverage truth |
### Phase E — Enforce RBAC-Safe Coverage Continuity
**Goal**: Ensure coverage surfaces can cite the backing run without leaking inaccessible operations.
| Step | File | Change |
|------|------|--------|
| E.1 | `app/Support/Inventory/TenantCoverageTruthResolver.php` and calling surfaces | Include basis-run identity and safe continuity metadata without assuming the current user can always open the run |
| E.2 | `app/Filament/Widgets/Inventory/InventoryKpiHeader.php` and `app/Filament/Pages/InventoryCoverage.php` | Show direct links only when the current user can open the run; otherwise show explanatory guidance, explicit no-sync copy, and provider or permission follow-up guidance |
| E.3 | `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php` and a new resolver or feature test for run continuity | Assert 404 or 403 behavior remains unchanged and the coverage UI degrades safely for users who cannot open the run |
### Phase F — Regression Protection and Verification
**Goal**: Lock the corrected semantics in place with focused tests and formatting.
| Step | File | Change |
|------|------|--------|
| F.1 | `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php` | Cover basis-run selection, `Unknown` derivation, follow-up classification, and item-count joining |
| F.2 | Existing feature tests across Inventory, Filament, Operations, and RBAC | Cover KPI wording, truth-first coverage page behavior, run-detail readability, and safe continuity |
| F.3 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Apply formatting and run the smallest verification pack covering resolver logic, inventory surfaces, run detail, and RBAC continuity |
## Key Design Decisions
### D-001 — The basis run is the latest completed inventory-sync run with usable per-type coverage payload
This feature needs `Failed` and `Skipped` to remain visible when a run produced real per-type truth, so the basis selector must key off payload presence rather than optimistic run outcome alone.
### D-002 — `InventoryCoverage` stays a low-level parser; tenant synthesis lives in a sibling contract and resolver
`InventoryCoverage` already models the canonical `context.inventory.coverage` payload. Extending it to select runs, join item counts, and apply UI-facing follow-up logic would blur responsibilities and make the low-level parser less reusable.
### D-003 — Count-based KPI signals are the narrowest correction
The spec explicitly identifies the unqualified percentage as the misleading element. Replacing it with succeeded and follow-up type counts corrects the operator semantics without inventing a new score.
### D-004 — Capability metadata stays visible but subordinate
The product support matrix is still useful, but it must stop being the primary answer to a tenant coverage question. The plan keeps the metadata in secondary table columns or reference treatment instead of removing it completely.
### D-005 — No new stale state family in this slice
The spec allows stale semantics as optional. Adding them now would create another interpretation layer and broaden the scope beyond the immediate truth correction. This slice uses explicit timestamps and leaves broader freshness posture to later health work.
### D-006 — Follow-up priority is deterministic and severity-first
Coverage surfaces must not invent their own urgency rules. Follow-up ordering is `Failed` before `Unknown` before `Skipped`, then observed item count descending, then type label ascending, so the summary and the table can highlight the same first-review candidates without presentation drift.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| The resolver selects a run that should not be the coverage basis | High | Medium | Unit-test the basis selector across succeeded, partial, failed-with-payload, skipped-with-payload, and no-payload scenarios |
| Observed item counts are read as proof of coverage | High | Medium | Separate item counts from state columns and summary language, and add regression tests for types with items but `Unknown` coverage |
| Capability metadata still dominates the coverage page | Medium | Medium | Lead with coverage-state columns and summary facts, demote support metadata to secondary columns, and add page-level assertions |
| Run drill-through leaks inaccessible operations or creates dead links | High | Medium | Compute safe continuity metadata in the resolver or calling surface and test authorized, forbidden, and not-found paths |
| Run-detail coverage rendering conflicts with the existing enterprise-detail hierarchy | Medium | Low | Use one enterprise-detail view section under existing conventions and keep raw context JSON in the technical section |
## Test Strategy
- Add focused unit coverage for `TenantCoverageTruthResolver` so basis-run selection, `Unknown` derivation, and follow-up classification are verified without UI noise.
- Extend `InventoryCoverageTableTest` and `InventoryCoverageAdminTenantParityTest` to prove the page now answers tenant coverage truth first, applies deterministic follow-up priority, handles the no-basis-run case plainly, and keeps support, restore, and compare metadata secondary.
- Extend inventory list and page regressions so the KPI summary no longer exposes a misleading coverage percentage, plainly reports when no basis run exists, and preserves existing sync-start affordances plus canonical run links.
- Extend `TenantlessOperationRunViewerTest` and `OperationRunEnterpriseDetailPageTest` to verify inventory-sync runs render a readable per-type coverage section, provider or permission follow-up guidance, and keep execution outcome separate from coverage truth.
- Add RBAC coverage for safe continuity so users who can see inventory truth but cannot open the run receive non-clickable or explanatory guidance rather than broken drill-throughs.
- Run the smallest focused Sail test pack plus Pint before implementation completion.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| New derived runtime contract plus resolver | Three existing surfaces need the same tenant coverage synthesis from run payload, item counts, and capability metadata | Recomputing the join inside each widget or page would duplicate business truth and make regression drift likely |
## Proportionality Review
- **Current operator problem**: Operators currently read a `Coverage` KPI that actually measures restorable-item share and a coverage page that primarily presents product support metadata, while the true per-type sync result lives in run context and is difficult to read.
- **Existing structure is insufficient because**: No existing object combines the relevant run basis, per-type run status, item counts, and support metadata into one tenant-coverage answer, and the current surfaces each infer their own incomplete version of coverage.
- **Narrowest correct implementation**: Add one derived runtime contract plus one resolver, then refit the existing KPI header, coverage page, and run-detail page around that contract without adding persistence or a broader framework.
- **Ownership cost created**: One new runtime contract, one resolver, one custom enterprise-detail view, and a focused set of unit and feature regressions.
- **Alternative intentionally rejected**: A persisted coverage table, a new percentage score, a request-scoped aggregate framework, or a broader inventory health layer were rejected because they exceed the scope of correcting already-available truth.
- **Release truth**: Current-release truth correction.

View File

@ -0,0 +1,64 @@
# Quickstart: Inventory Coverage Truth (177)
## Goal
Implement the Spec 177 truth correction without changing inventory-sync execution semantics or adding persistence.
The implementation is complete when:
- the inventory KPI header no longer shows a misleading unqualified coverage percentage,
- the coverage page answers tenant coverage truth first,
- inventory-sync run detail shows per-type results in human-readable form,
- and run continuity is RBAC-safe.
## Suggested Implementation Order
1. Add the derived runtime contract and resolver under `app/Support/Inventory`.
2. Add unit tests for basis-run selection, `Unknown` derivation, and follow-up classification.
3. Refactor `InventoryKpiHeader` to consume the new resolver and switch to count-based summary facts.
4. Refactor `InventoryCoverage` to consume the same resolver and move capability metadata into secondary treatment.
5. Add the inventory-sync run detail section and its custom Blade view.
6. Extend RBAC and feature tests for safe continuity and truthful rendering.
7. Run Pint and the focused Sail test pack.
## Verification Workflow
### Unit and focused feature tests
Run the smallest focused set first:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas
vendor/bin/sail artisan test --compact tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryCoverageTableTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php
vendor/bin/sail artisan test --compact tests/Feature/Operations/TenantlessOperationRunViewerTest.php
vendor/bin/sail artisan test --compact tests/Feature/Inventory/RunInventorySyncJobTest.php
vendor/bin/sail bin pint --dirty --format agent
```
### Manual operator walkthrough
1. Open the inventory items list for a tenant with a recent inventory-sync run.
2. Confirm the header shows count-based coverage truth, not `Coverage %`.
3. Open the coverage page and verify the summary cites the basis run and the per-type table leads with coverage state and follow-up.
4. Open the cited inventory-sync run and verify the new per-type coverage section renders without opening raw JSON.
5. Verify a user without access to the run sees safe explanatory guidance instead of a dead-end link.
## Out-of-Scope Guardrails
Do not do any of the following in this slice:
- add a new coverage table or materialized summary artifact
- rewrite `InventorySyncService` or `RunInventorySyncJob`
- introduce restore-readiness or compare-readiness semantics into coverage
- add a first-class stale coverage state family
- add page-local badge mappings or local semantic color logic
## Completion Checklist
- Count-based KPI summary shipped
- Coverage page is tenant-truth-first
- Capability metadata is visibly secondary
- Run detail has human-readable per-type coverage
- Safe continuity works for both authorized and unauthorized viewers
- Focused tests and Pint pass

View File

@ -0,0 +1,66 @@
# Phase 0 Research: Inventory Coverage Truth (177)
## Context
Spec 177 corrects a semantic trust problem on the existing inventory surfaces.
The current inventory KPI widget computes `Coverage %` from restorable-item share inside `InventoryKpiHeader`, while real per-type sync truth is already persisted in canonical `OperationRun.context['inventory']['coverage']` by `InventorySyncService` and `RunInventorySyncJob`. The current `InventoryCoverage` page is config-driven and capability-first, and inventory-sync run detail still leaves the per-type result largely hidden behind generic run outcome and raw JSON.
The feature must stay derived, tenant-scoped, and operator-first.
## Decisions
### Decision: The coverage basis is the latest completed inventory-sync run with parseable per-type coverage payload
- **Rationale**: The spec needs `Succeeded`, `Failed`, `Skipped`, and `Unknown` to be visible as tenant coverage truth. The job currently writes a normalized `context.inventory.coverage` payload before terminalizing the run, including skipped and failed cases that still carry real per-type truth. The narrowest deterministic rule is therefore to select the latest completed `inventory_sync` run for the tenant whose payload can be parsed by `InventoryCoverage::fromContext()`.
- **Alternatives considered**:
- Latest succeeded or partially succeeded run only: rejected because it would hide relevant skipped or failed per-type truth that the spec explicitly wants operators to see.
- Latest attempted run regardless of payload: rejected because a run without parseable coverage payload cannot support per-type coverage truth and would collapse all rows into guesswork.
### Decision: Coverage remains fully derived from existing truth sources
- **Rationale**: The feature can answer the tenant coverage question by combining three already-existing sources: canonical per-type sync truth from `OperationRun.context.inventory.coverage`, observed-item counts from `InventoryItem`, and product capability metadata from `InventoryPolicyTypeMeta` plus `CoverageCapabilitiesResolver`. This satisfies the constitution bias toward deriving before persisting.
- **Alternatives considered**:
- New coverage table or materialized snapshot: rejected because it would duplicate current-release truth and add lifecycle overhead without new operator value.
- Writeback summary JSON to `Tenant`: rejected because the truth already belongs to the latest inventory-sync run and current observed items.
### Decision: Introduce one narrow runtime contract and resolver as siblings to `InventoryCoverage`
- **Rationale**: `InventoryCoverage` is already the canonical parser for the stored run payload. Extending it to perform tenant-scoped run lookup, item-count joins, and follow-up classification would blur responsibilities. A sibling runtime contract such as `TenantCoverageTruth` and a resolver such as `TenantCoverageTruthResolver` keep the low-level parser small while giving the UI one stable read model.
- **Alternatives considered**:
- Add more behavior directly to `InventoryCoverage`: rejected because it would mix raw payload normalization with tenant-level query and presentation concerns.
- Compute the join independently inside each page or widget: rejected because three surfaces would re-own the same truth and regress independently.
- Add request-scoped aggregate caching: rejected as unnecessary complexity for this slice.
### Decision: Replace the KPI percentage with count-based coverage facts
- **Rationale**: The spec explicitly says absolute counts are preferred over a percentage unless the percentage is narrowly qualified. Count-based facts such as succeeded types, types needing follow-up, last sync, and items observed answer the operator question directly and avoid false completeness signals.
- **Alternatives considered**:
- Keep a relabeled percentage such as `Latest sync type coverage`: rejected for the first slice because counts are clearer and avoid another interpretation layer.
- Keep the current restorable-item share with different wording: rejected because it still answers the wrong question.
### Decision: The coverage page becomes one tenant-coverage-first report with capability metadata demoted to secondary treatment
- **Rationale**: The current page already has a searchable and filterable table surface. The narrowest correction is to reuse that surface, rebuild the row model around tenant coverage truth, lead with summary + state + follow-up columns, and keep capability metadata in secondary columns or labeled reference treatment.
- **Alternatives considered**:
- Preserve the current capability-first matrix and add a separate banner: rejected because the primary semantic center would remain wrong.
- Split the page into two separate tables for tenant truth and product support: rejected as broader than needed for the first correction slice.
### Decision: Inventory-sync run detail gets one human-readable per-type coverage section under the existing enterprise-detail stack
- **Rationale**: `OperationRunResource` already uses `EnterpriseDetailBuilder` with custom view sections. Adding one `inventory_sync`-specific section under the same pattern is the narrowest way to expose per-type results without inventing a new operational page.
- **Alternatives considered**:
- Continue relying on raw context JSON: rejected because the spec explicitly forbids leaving this truth buried in JSON.
- Build a standalone inventory-sync detail page: rejected because the canonical run viewer already exists.
### Decision: Do not introduce a first-class stale or freshness coverage state in Spec 177
- **Rationale**: The spec lists stale semantics as optional secondary behavior. The current trust defect is the wrong meaning of coverage, not missing freshness taxonomy. Showing the basis timestamp is enough for this slice and avoids broadening the state family.
- **Alternatives considered**:
- Add `Stale` now as a fifth primary coverage state: rejected because it would expand scope into inventory health and freshness semantics better handled by a later follow-up spec.
### Decision: Run continuity must be RBAC-safe and explanatory when drill-through is unavailable
- **Rationale**: The spec requires that coverage surfaces never emit broken or implicitly inaccessible next actions. The UI must only link to the basis run when the user is entitled to open it; otherwise it must show clear non-clickable guidance.
- **Alternatives considered**:
- Always show the run link and let authorization fail after navigation: rejected because it creates dead-end operator flows and can leak existence.
- Hide all run references unless the user can open them: rejected because the spec still requires clear explanation of what the coverage statement is based on.
## Clarifications Resolved
- **Relevant inventory sync**: The basis run is payload-bearing and completed; outcome alone is not sufficient.
- **Unknown semantics**: `Unknown` means there is no current tenant coverage result for that supported type in the chosen basis run, even if items still exist from older observation.
- **Capability separation**: Restore mode, risk, dependency support, and similar metadata remain visible only as secondary support reference, not as coverage truth.
- **Scope limit**: No new persistence, no backend rewrite, and no freshness-state expansion are included in this slice.

View File

@ -0,0 +1,235 @@
# Feature Specification: Spec 177 - Inventory Coverage Truth
**Feature Branch**: `feat/177-inventory-coverage-truth`
**Created**: 2026-04-05
**Status**: Draft
**Input**: User description: "Spec 177 - Inventory Coverage Truth"
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant + canonical-view
- **Primary Routes**:
- `/admin/inventory-items` and inventory item detail routes while a tenant context is active
- `/admin/coverage` while a tenant context is active
- `/admin/operations` and `/admin/operations/{run}` for canonical inventory-sync drill-through
- **Data Ownership**:
- Tenant-owned `InventoryItem` rows and per-type observed item counts remain the tenant inventory observation truth.
- Workspace-owned `OperationRun` rows with a tenant reference remain the canonical execution truth for `inventory_sync`, including the existing per-type payload stored under `context.inventory.coverage`.
- Product capability and support metadata remain derived from the supported-type catalog and capability metadata already exposed through inventory policy-type meta and related capability resolvers.
- This feature introduces no new persisted coverage table, no materialized coverage snapshot, and no writeback artifact.
- **RBAC**:
- Workspace membership, tenant entitlement, and tenant inventory view capability remain required for the inventory items list and the coverage page.
- Starting an inventory sync from the inventory items list remains gated by the canonical tenant inventory sync capability.
- Inventory-sync run drill-through remains governed by existing operation-run authorization: workspace membership, tenant entitlement when a run is tenant-bound, and any run-specific required capability already attached to that run.
- Coverage surfaces must never expose broken or unauthorized next-action links; when a user can see coverage truth but cannot open the backing run, the UI must degrade safely.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Coverage-to-operations navigation opens the exact latest relevant inventory-sync run when one is available. If the operator needs the broader operations list instead, the canonical destination opens prefiltered to the active tenant and the inventory-sync operation family.
- **Explicit entitlement checks preventing cross-tenant leakage**: Coverage summary counts, per-type rows, observed-item counts, run references, and follow-up actions must be derived only from the active tenant scope and from operation runs the current user is entitled to inspect. Non-members remain deny-as-not-found, and inaccessible runs must not leak existence through clickable dead ends.
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Inventory items list with KPI summary | Read-only Registry / Report Surface | Full-row click to inventory item detail remains the one inspect model for item records | required | `Run Inventory Sync` stays in the header; coverage and run continuity links stay in the KPI summary or adjacent summary content | none | `/admin/inventory-items` | Inventory item detail route for the selected tenant item | Active tenant context, current filters, and the last relevant sync reference anchor the list to one tenant | Inventory Items / Inventory Item | Observed items plus truthful tenant coverage summary, including latest sync reference and follow-up counts rather than a misleading restorable-item percentage | Embedded summary surface inside a read-only resource |
| Inventory coverage page | Read-only Registry / Report Surface | The page itself is the canonical tenant coverage report; diagnostics drill through via explicit summary links to the relevant sync run | forbidden | Summary-level diagnostic and retry actions live above or beside the per-type table; capability reference remains secondary on the page | none | `/admin/coverage` | `/admin/operations/{run}` for the cited relevant inventory-sync run | Active tenant context, cited run timestamp, cited run identity, and visible succeeded or failed or skipped or unknown counts | Inventory Coverage | Per-type tenant coverage truth, follow-up need, and the run the truth is based on, clearly separated from product capability reference | Derived per-type rows have no standalone record detail |
| Inventory sync run detail | Detail-first Operational Surface | Dedicated run detail page | forbidden | Existing back, refresh, related-link, and safe diagnostic actions remain in the detail header | none | `/admin/operations` | `/admin/operations/{run}` | Workspace context, referenced tenant, run timing, run outcome, and inventory-sync identity | Inventory Sync Run / Operation Run | Execution outcome plus human-readable per-type coverage results instead of raw JSON alone | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Inventory items list with KPI summary | Tenant operator | List with embedded summary | What has this tenant inventory observed, and does current coverage need follow-up before I trust that view? | Total observed items, last relevant sync reference, counts of succeeded types and types needing follow-up, and the inventory item list itself | Raw run context, low-level provider failure details, and secondary capability metadata | coverage truth, execution recency, item presence | `Run Inventory Sync` continues to affect the Microsoft tenant and TenantPilot inventory observation state; the list itself is read-only | Open inventory item, Run Inventory Sync, Open coverage truth | none |
| Inventory coverage page | Tenant operator | Derived report | Which supported types are currently covered for this tenant, which are not, and what should I do next? | Coverage summary, follow-up summary, cited relevant sync, and a per-type table showing state, timestamp or run reference context, observed item count, and follow-up need | Secondary support or capability reference, dependency capability, raw reason codes, and deep diagnostics | coverage truth, execution truth, item presence, capability reference as a separate domain | none on the report itself; any sync retry action keeps the existing inventory-sync mutation scope | View latest sync run, Run inventory sync again when authorized, Review follow-up types | none |
| Inventory sync run detail | Tenant operator or workspace operator with tenant entitlement | Detail | What did this inventory sync actually do per type, and how does that explain the tenant's current coverage truth? | Run status and outcome, human-readable per-type results, counts, related tenant context, and next-step guidance | Full context JSON, raw failure payloads, and deeper technical fragments | execution outcome, per-type coverage result, item counts, next-step guidance | none on the detail page itself | Refresh, open related coverage context, open related records | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: No.
- **New persisted entity/table/artifact?**: No.
- **New abstraction?**: Yes, one narrow derived coverage-truth assembler or read model may be required to combine supported types, the latest relevant inventory-sync result, observed item counts, and follow-up classification on existing surfaces.
- **New enum/state/reason family?**: Yes, a narrow derived tenant-coverage state family must include `Unknown` or `Not synced yet` alongside the existing successful, failed, and skipped outcomes. It remains derived, not persisted.
- **New cross-domain UI framework/taxonomy?**: No.
- **Current operator problem**: Operators currently see a `Coverage` KPI that is really a restorable-item share, while real per-type sync truth lives in inventory-sync run context and is almost invisible on the operator-facing surfaces that should answer coverage questions.
- **Existing structure is insufficient because**: The KPI widget currently compresses restorable-item share into `Coverage %`, the coverage page renders a capability or support matrix rather than tenant sync truth, and inventory-sync run detail does not expose per-type results in an operator-first format.
- **Narrowest correct implementation**: Derive one tenant coverage view from the existing latest relevant inventory-sync run, the supported-type catalog, and current inventory-item counts; surface it on the existing inventory pages and inventory-sync run detail; keep product capability reference secondary; add no new persistence.
- **Ownership cost**: Focused derived-read-model logic, presentation cleanup on three existing surfaces, and regression coverage across tenant coverage, operations drill-through, and RBAC-safe degradation.
- **Alternative intentionally rejected**: A new coverage table, a restore-readiness score, a compare-readiness layer, a dashboard-wide inventory health program, or a broader content-depth taxonomy were rejected because the immediate defect is misleading operator truth on already-shipped inventory surfaces.
- **Release truth**: Current-release truth correction that prepares later usefulness, missing-item, and dashboard follow-up work.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Read truthful coverage at a glance (Priority: P1)
As a tenant operator, I can open inventory surfaces and immediately understand which supported inventory types are currently covered for my tenant and which types still need follow-up.
**Why this priority**: The current trust failure starts at first glance. If the leading KPI and coverage page are semantically wrong, every later decision is biased by false calm.
**Independent Test**: Can be fully tested by seeding one tenant with a latest relevant inventory-sync run that contains a mix of succeeded, failed, skipped, and omitted types plus current inventory items, then rendering the inventory items list and the coverage page.
**Acceptance Scenarios**:
1. **Given** a tenant has a latest relevant inventory-sync run with mixed per-type outcomes, **When** the operator opens the coverage page, **Then** every supported type appears with a truthful state of succeeded, failed, skipped, or unknown and the follow-up summary highlights the non-succeeded types.
2. **Given** a supported type has observed items but no current coverage result in the relevant sync basis, **When** the operator opens the coverage surface, **Then** the type appears as unknown or not synced yet rather than implicitly covered.
3. **Given** no relevant inventory-sync run exists for the tenant, **When** the operator opens the inventory summary surfaces, **Then** the UI makes clear that there is no current coverage result and points the operator toward starting inventory sync instead of presenting a positive coverage claim.
---
### User Story 2 - Move cleanly from coverage truth to run truth (Priority: P1)
As a tenant operator, I can see which inventory-sync run the current coverage statement is based on and open that run or a safe equivalent diagnostic path without losing tenant context.
**Why this priority**: Coverage claims must be recoverable. If the UI states that a tenant is covered or not covered but cannot show the exact run that established that claim, the truth is not auditable.
**Independent Test**: Can be fully tested by seeding a tenant with a relevant inventory-sync run and verifying that the coverage page and inventory summary surfaces cite the run, the timestamp, and the correct drill-through behavior for both authorized and unauthorized viewers.
**Acceptance Scenarios**:
1. **Given** current coverage is derived from a specific inventory-sync run, **When** the operator opens the coverage page, **Then** the page shows the cited run and timestamp and provides a direct path to that run.
2. **Given** the current user can see inventory truth but cannot open the backing run, **When** the coverage page renders, **Then** the page shows safe explanatory guidance instead of a broken or unauthorized run link.
3. **Given** the operator needs the broader operations history, **When** the operator follows the diagnostic path from inventory coverage, **Then** the operations destination stays scoped to the originating tenant and the inventory-sync operation family.
---
### User Story 3 - Diagnose per-type inventory-sync results without raw JSON (Priority: P2)
As a tenant operator, I can open an inventory-sync run detail page and read the per-type results in human terms, so I understand how execution outcome and coverage follow-up relate.
**Why this priority**: The backend already knows this truth. The missing value is readable diagnosis, not a new execution system.
**Independent Test**: Can be fully tested by seeding an inventory-sync run whose per-type payload contains mixed outcomes and verifying that the run detail page renders a readable per-type breakdown and next-step guidance without requiring raw JSON inspection.
**Acceptance Scenarios**:
1. **Given** an inventory-sync run has per-type coverage payload data, **When** the operator opens the run detail page, **Then** the page shows a human-readable per-type result section rather than burying the truth only in raw context JSON.
2. **Given** the run outcome is partially successful, **When** the operator views run detail, **Then** the page separates overall execution outcome from the specific types that still need follow-up.
3. **Given** one or more types failed or were skipped, **When** the operator views run detail, **Then** the next-step guidance points to the relevant follow-up path such as reviewing provider or permission problems or running inventory sync again.
### Edge Cases
- The tenant has never completed a relevant inventory-sync run, so all supported types must remain unknown instead of reading as covered by default.
- The latest relevant run omitted one or more supported types entirely, which must result in unknown rather than silent success.
- Observed items exist from an older run, but the latest relevant run did not establish current coverage truth for that type.
- The latest relevant run failed or was blocked before processing most or all selected types.
- A type was intentionally skipped because of selection or foundation toggles, and the UI must show that truth without implying success.
- The current user may view inventory surfaces but may not satisfy the required capability for the cited inventory-sync run.
- The supported-type catalog may change after the latest sync, so the coverage surface must distinguish current support reference from tenant sync truth.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new change operation, and no new long-running workflow. It reuses the existing `inventory_sync` operation truth already written into canonical `OperationRun` records and corrects how that truth is surfaced on inventory pages.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature is intentionally narrow. It introduces no new persistence and no generic framework. A single derived coverage-truth assembler and one derived `Unknown` coverage state are justified because the existing surfaces currently misrepresent three different truths as one. The feature follows the default bias of deriving before persisting, replacing misleading semantics before layering new ones, and being explicit instead of generic.
**Constitution alignment (OPS-UX):** The existing `inventory_sync` `OperationRun` remains canonical. This feature does not change queued-toast, progress-surface, or terminal-notification behavior. `OperationRun.status` and `OperationRun.outcome` remain service-owned through `OperationRunService`, `summary_counts` remain numeric-only and canonical, scheduled or system-run behavior is unchanged, and regression coverage must verify run-detail rendering and coverage-to-run continuity without inventing new operation feedback surfaces.
**Constitution alignment (RBAC-UX):** This feature spans tenant inventory surfaces on `/admin` with active tenant context and canonical operations surfaces on `/admin/operations`. Non-members or actors outside the current tenant scope remain `404`. In-scope members missing the required run capability remain `403` on the run detail itself. Server-side authorization remains the source of truth through the existing capability resolver on inventory surfaces and `OperationRunPolicy` plus run-capability resolution for run drill-through. No raw capability strings or role-string checks are introduced. No new destructive action is added.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
**Constitution alignment (BADGE-001):** Any added or updated coverage-state badges must stay centralized. `Succeeded`, `Failed`, `Skipped`, and `Unknown` must use shared badge semantics rather than page-local color language, and regression tests must cover the new or revised mappings.
**Constitution alignment (UI-FIL-001):** The feature reuses Filament stats widgets, tables, sections, badges, infolists, and actions already present on the affected inventory and operations surfaces. It avoids local replacement markup for status language and does not require publishing internal Filament views. No exception is expected.
**Constitution alignment (UI-STD-001):** The modified inventory items list and inventory coverage page are governed by `docs/product/standards/list-surface-review-checklist.md`, and implementation must review both surfaces against that checklist before sign-off.
**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary must distinguish `Coverage`, `Support`, `Capability`, `Restore mode`, `Compare`, `Last sync`, and `Needs follow-up`. `Coverage` means tenant-specific sync coverage for supported types. `Coverage %` is forbidden unless explicitly qualified as the latest sync's type coverage. `Restore ready` and `Compare ready` must not be used as synonyms for coverage.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The inventory items list keeps row click for real inventory records. The inventory coverage page remains a derived report with an approved exception: its per-type rows do not open a standalone detail record. Diagnostics drill through through explicit links to the relevant run. The canonical collection and detail routes are stated above, and critical truth visible by default must be tenant coverage truth rather than support metadata.
**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first. Coverage truth, execution truth, and item presence must be shown as separate dimensions. Capability and support metadata remain diagnostics or secondary reference. The existing `Run Inventory Sync` action continues to communicate a tenant-affecting sync over the external tenant plus TenantPilot observation state and follows the existing safe execution pattern.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from one existing source is insufficient because current truth is split across operation-run context, inventory-item counts, and supported-type metadata. This feature may introduce one narrow derived coverage view, but it must replace the misleading KPI and capability-centric interpretation rather than add a new general semantic framework. Tests must focus on operator consequences: truthful summaries, safe follow-up, and auditable continuity.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied with one approved exception on `InventoryCoverage`: derived per-type rows do not have a standalone record detail. Redundant `View` actions remain absent, empty action groups remain absent, and no new destructive action is introduced. UI-FIL-001 is satisfied with no approved exception beyond the existing derived-row detail exemption.
**Constitution alignment (UX-001 - Layout & Information Architecture):** The inventory coverage page remains a search and filter driven table surface with a truthful summary above the table and a clear single empty-state CTA. The inventory items list remains a read-only list and detail flow. Inventory-sync run detail remains a view-style operational page. Any new summary panels or diagnostic sections must use shared sections or cards and keep raw JSON secondary.
### Functional Requirements
- **FR-177-001**: Default-visible inventory summary surfaces MUST NOT present a KPI or label that can be read as tenant inventory completeness when it actually represents restorable-item share, capability coverage, or any other secondary truth. The current unqualified `Coverage %` must be removed or replaced with an explicitly qualified type-coverage statement.
- **FR-177-002**: The tenant coverage report MUST show every supported inventory type for the selected tenant with, at minimum, the type, current coverage state, the relevant sync reference or time basis, observed item count, and whether follow-up is needed.
- **FR-177-003**: Coverage state MUST be derived from real inventory-sync truth and MUST include at least `Succeeded`, `Failed`, `Skipped`, and `Unknown` or `Not synced yet`.
- **FR-177-004**: Coverage surfaces MUST make clear which relevant inventory-sync run they are based on, including a visible timestamp and a direct or safely degraded path to the canonical run detail. If no relevant sync exists, the surface must say so plainly.
- **FR-177-005**: Run execution truth and coverage truth MUST remain separate. A partial or failed run outcome must not replace the per-type statement of which supported types are currently covered and which still need follow-up.
- **FR-177-006**: The inventory coverage page MUST primarily answer the tenant question `Which supported types are currently inventoried successfully for this tenant, and where are the gaps?` It must not remain centered on a product support matrix.
- **FR-177-007**: Product support and capability metadata MAY remain available only as a clearly secondary reference layer with labels such as `Support`, `Capability`, or `Restore mode`. It must not be confused with tenant coverage truth.
- **FR-177-008**: When one or more supported types are failed, skipped, or unknown, the coverage surfaces MUST prominently signal that follow-up is required, how many types are affected, and which types are highest priority to review first. Follow-up priority MUST use a deterministic severity-first order of `Failed` before `Unknown` before `Skipped`, then observed item count descending, then inventory type label ascending for stable ties.
- **FR-177-009**: A supported type without a current tenant coverage result MUST appear as `Unknown` or `Not synced yet`. It must not appear positively because items exist, because the type is supported, or because the product has richer capability metadata for it.
- **FR-177-010**: Observed item counts MUST remain clearly separate from coverage truth and MUST NOT be styled or worded as proof of completeness, restore usefulness, or compare usefulness.
- **FR-177-011**: Coverage surfaces MUST provide real next actions such as viewing the latest relevant run, running inventory sync again, reviewing failed or skipped types, or reviewing provider or permission issues. When a next action cannot be opened by the current user, the surface MUST show safe explanatory guidance instead of a dead-end link.
- **FR-177-012**: Inventory-sync run detail MUST expose per-type results in a human-readable section and MUST NOT rely on raw JSON alone to communicate which supported types succeeded, failed, or were skipped.
- **FR-177-013**: All coverage signals, per-type states, counts, and run drill-throughs MUST remain tenant-scoped and RBAC-conformant.
- **FR-177-014**: Coverage MUST NOT imply restore readiness. Any restore metadata shown on the same surface must remain explicitly separate from the coverage statement.
- **FR-177-015**: Coverage MUST NOT imply compare readiness. Any compare-related context shown on the same surface must remain secondary and explicitly distinct from coverage truth.
- **FR-177-016**: The feature MUST be derived from existing inventory items, supported-type metadata, and canonical inventory-sync run truth without adding a new coverage persistence model or rewriting the inventory-sync backend.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Inventory items list with KPI summary | `app/Filament/Resources/InventoryItemResource.php`, `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`, `app/Filament/Widgets/Inventory/InventoryKpiHeader.php` | `Run Inventory Sync` | Existing one-click open path to inventory item detail remains the only inspect model for item rows | none | none | Existing inventory list empty-state CTA remains unchanged | n/a | n/a | existing only for sync dispatch | KPI labels, summary facts, and coverage or run continuity links are changed by this spec. The sync action remains capability-gated and non-destructive. |
| Inventory coverage page | `app/Filament/Pages/InventoryCoverage.php` | none; summary-level diagnostic or retry CTAs may appear above the table | Approved exception: derived per-type rows do not have a standalone detail record | none | none | `Clear filters` | n/a | n/a | no new audit behavior | Action Surface Contract remains satisfied through the approved derived-row exception. Run continuity is provided through explicit summary links rather than row inspect actions. |
| Inventory sync run detail | `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and the existing operation-run detail rendering stack | Existing back, refresh, and related-link header actions remain | Direct page | n/a | none | n/a | Existing back, refresh, and related-link header actions remain | n/a | no new audit behavior | This spec adds human-readable per-type inventory coverage results to the existing detail page and does not introduce new mutations. |
### Key Entities *(include if feature involves data)*
- **Tenant coverage truth**: The tenant-specific statement of whether each supported inventory type is currently covered successfully, failed, skipped, or still unknown.
- **Relevant inventory-sync run**: The latest completed `inventory_sync` execution for the active tenant that contains parseable `context.inventory.coverage` truth. Overall run outcome does not disqualify it when usable per-type payload exists, because `Failed` and `Skipped` remain meaningful operator truth.
- **Coverage state**: The per-type result family used for operator decisions: `Succeeded`, `Failed`, `Skipped`, and `Unknown` or `Not synced yet`.
- **Observed item count**: The current number of inventory items observed for one supported type in the selected tenant, kept separate from coverage truth.
- **Capability reference**: Static product support metadata such as support mode, restore mode, dependency capability, risk, or similar type metadata that must remain distinct from tenant coverage truth.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-177-001**: In seeded regression scenarios, the default-visible inventory items summary, inventory coverage page, and inventory-sync run detail together expose covered versus follow-up types, basis-run context, and next-step guidance without requiring raw JSON inspection.
- **SC-177-002**: In regression coverage, 100% of default-visible inventory summary surfaces stop showing an unqualified `Coverage %` or any equivalent metric that can be read as tenant inventory completeness.
- **SC-177-003**: In regression coverage, every supported type shown on the tenant coverage report resolves to exactly one of `Succeeded`, `Failed`, `Skipped`, or `Unknown`, and the summary follow-up count matches the number of non-succeeded types.
- **SC-177-004**: In tested authorization scenarios, operators can either open the cited relevant inventory-sync run from coverage surfaces or receive a safe non-clickable explanation in 100% of cases.
- **SC-177-005**: The feature ships without a required schema migration, without a new coverage table, and without a rewrite of the inventory-sync backend.
## Assumptions
- Existing inventory-sync runs already persist per-type successful or failed or skipped truth for attempted types inside canonical `OperationRun` context.
- The supported and foundation type catalog remains the authoritative denominator for tenant coverage reporting.
- `Unknown` is a derived tenant state used when current coverage truth for a supported type cannot be established from the relevant sync basis, even if older items still exist.
- Inventory item list and detail pages remain the correct places for item-level observation; this spec does not promote them into restore-readiness or compare-readiness surfaces.
## Non-Goals
- Redesigning the backup or restore domain
- Introducing a restore-readiness algorithm or item-level readiness score
- Introducing a compare-readiness domain for inventory coverage
- Building a missing or vanished item workflow
- Launching a dashboard-wide inventory health program
- Adding a new persistence table or materialized coverage artifact
- Rewriting the inventory-sync backend
## Dependencies
- Spec 039 - Inventory Program
- Spec 040 - Inventory Core
- Spec 041 - Inventory UI
- Spec 042 - Inventory Dependencies Graph
- Existing inventory surfaces, inventory-sync execution, supported-type metadata, and canonical operations drill-through behavior already present in the repo
## Follow-up Spec Candidates
- **Inventory Content Depth & Usefulness**: Separate coverage truth from content depth, restore usefulness, and compare usefulness.
- **Inventory Missing / Vanished Surface**: Add explicit follow-up for missing or vanished items once coverage truth is no longer overloaded.
- **Dashboard Inventory Health**: Propagate truthful coverage and freshness signals onto broader tenant overview surfaces after the core coverage semantics are corrected.
## Definition of Done
Spec 177 is complete when:
- no operator can read a default-visible coverage metric as tenant inventory completeness when it is actually describing capability or restorable-item share,
- the inventory coverage page shows truthful per-type tenant coverage and makes follow-up obvious,
- the inventory items list and its KPI summary cite truthful coverage and last-sync context rather than a misleading percentage,
- inventory-sync run detail exposes per-type results in human-readable form,
- capability and support metadata remain available but secondary and explicitly labeled,
- coverage-to-run continuity is auditable and RBAC-safe,
- and the improvement ships without a new persisted coverage model or an inventory-sync backend rewrite.

View File

@ -0,0 +1,224 @@
# Tasks: Inventory Coverage Truth
**Input**: Design documents from `/specs/177-inventory-coverage-truth/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Required. Use Pest coverage in `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php`, `tests/Unit/Badges/BadgeCatalogTest.php`, `tests/Unit/Badges/InventoryCoverageStateBadgesTest.php`, `tests/Feature/Filament/InventoryCoverageTableTest.php`, `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`, `tests/Feature/Filament/InventoryPagesTest.php`, `tests/Feature/Filament/InventoryItemResourceTest.php`, `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Inventory/InventorySyncServiceTest.php`, `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php`, `tests/Feature/Inventory/RunInventorySyncJobTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php`.
**Operations**: This feature reuses the existing `inventory_sync` `OperationRun` and does not introduce a new run type or change lifecycle ownership. Tasks must keep canonical run links and read-time rendering aligned without changing queued execution semantics.
**RBAC**: Existing workspace membership, tenant entitlement, and 404 vs 403 semantics remain authoritative. Tasks must preserve tenant-safe rendering and ensure basis-run links degrade safely when the current user cannot open the run.
**Operator Surfaces**: The inventory items list KPI header, the inventory coverage page, and canonical inventory-sync run detail must become tenant-coverage-first while keeping low-level diagnostics secondary.
**Filament UI Action Surfaces**: No new destructive actions or new action inventories are added. The existing `Run Inventory Sync` header action remains capability-gated and non-destructive, and the `InventoryCoverage` page keeps its derived-row no-detail exception.
**Filament UI UX-001**: No new create or edit screens are introduced. The inventory coverage page remains a searchable, filterable table with a single clear empty-state CTA, and run detail remains an enterprise-detail view surface.
**Badges**: Coverage-state semantics must stay centralized through `BadgeDomain`, `BadgeCatalog`, and `BadgeRenderer`; no page-local badge mappings are allowed.
**Organization**: Tasks are grouped by user story so each story can be implemented and validated as an independent increment once the shared tenant coverage contract is in place.
## Phase 1: Setup (Shared Coverage Truth Scaffolding)
**Purpose**: Create the narrow runtime and regression entry points required for tenant coverage truth.
- [X] T001 [P] Create the derived tenant coverage truth scaffolding in `app/Support/Inventory/TenantCoverageTruth.php` and `app/Support/Inventory/TenantCoverageTruthResolver.php`
- [X] T002 [P] Create the focused regression scaffolding in `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php`, `tests/Unit/Badges/InventoryCoverageStateBadgesTest.php`, and `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`
---
## Phase 2: Foundational (Blocking Coverage Contract)
**Purpose**: Build the shared derived coverage contract, basis-run selection, and centralized badge semantics that all user stories depend on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 [P] Extend basis-run and mixed-outcome payload coverage assertions in `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php` and `tests/Feature/Inventory/RunInventorySyncJobTest.php`
- [X] T004 [P] Add centralized inventory coverage badge mapping assertions in `tests/Unit/Badges/BadgeCatalogTest.php` and `tests/Unit/Badges/InventoryCoverageStateBadgesTest.php`
- [X] T005 [P] Implement the latest completed coverage-bearing inventory-sync selection helper in `app/Models/OperationRun.php`
- [X] T006 [P] Implement the derived runtime fields and row contract in `app/Support/Inventory/TenantCoverageTruth.php`
- [X] T007 Implement tenant coverage resolution, item-count joins, follow-up classification, and basis-run metadata in `app/Support/Inventory/TenantCoverageTruthResolver.php`
- [X] T008 Implement the centralized inventory coverage state badge domain and mapper in `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/BadgeCatalog.php`, and `app/Support/Badges/Domains/InventoryCoverageStateBadge.php`
**Checkpoint**: A single derived tenant coverage contract now exists for all supported types, with centralized badge semantics and deterministic basis-run selection.
---
## Phase 3: User Story 1 - Read Truthful Coverage At A Glance (Priority: P1) 🎯 MVP
**Goal**: Make the inventory KPI header and coverage page answer the tenant coverage question directly instead of implying completeness from restorable-item share or support metadata.
**Independent Test**: Seed a tenant with a coverage-bearing inventory-sync run that includes succeeded, failed, skipped, and omitted types plus current inventory items, then verify the inventory items list and coverage page show truthful counts, `Unknown` rows, and follow-up emphasis.
### Tests for User Story 1
- [X] T009 [P] [US1] Rewrite summary-surface expectations for truthful count-based coverage on the inventory items list in `tests/Feature/Filament/InventoryPagesTest.php` and `tests/Feature/Filament/InventoryItemResourceTest.php`
- [X] T010 [P] [US1] Add coverage-table assertions for `Succeeded`, `Failed`, `Skipped`, `Unknown`, deterministic follow-up priority, no-basis-run messaging, and secondary support, restore, and compare metadata in `tests/Feature/Filament/InventoryCoverageTableTest.php` and `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`
### Implementation for User Story 1
- [X] T011 [P] [US1] Replace the misleading coverage percentage with count-based tenant coverage facts, explicit no-basis-run messaging, and coverage-only terminology in `app/Filament/Widgets/Inventory/InventoryKpiHeader.php`
- [X] T012 [P] [US1] Rewrite KPI supporting badge copy around follow-up counts, top-priority follow-up types, and observed-item counts without implying restore or compare readiness in `app/Support/Inventory/InventoryKpiBadges.php`
- [X] T013 [US1] Refactor tenant coverage row assembly, deterministic follow-up ranking, and default-visible columns while keeping support, restore, and compare metadata secondary in `app/Filament/Pages/InventoryCoverage.php`
- [X] T014 [US1] Rewrite the coverage page intro and summary copy to tenant-coverage-first language with explicit no-sync fallback guidance in `resources/views/filament/pages/inventory-coverage.blade.php`
- [X] T015 [US1] Run the focused truthful-at-a-glance pack in `tests/Feature/Filament/InventoryCoverageTableTest.php`, `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`, `tests/Feature/Filament/InventoryPagesTest.php`, and `tests/Feature/Filament/InventoryItemResourceTest.php`
**Checkpoint**: The inventory items list and coverage page now show tenant coverage truth first, with capability metadata clearly secondary.
---
## Phase 4: User Story 2 - Move Cleanly From Coverage Truth To Run Truth (Priority: P1)
**Goal**: Make coverage surfaces cite the exact basis run, provide safe drill-through when authorized, and degrade cleanly when the run cannot be opened.
**Independent Test**: Seed a tenant with a coverage-bearing basis run and verify the inventory summary surfaces show the basis timestamp and run continuity, then verify a user without run access receives explanatory guidance instead of a dead-end link.
### Tests for User Story 2
- [X] T016 [P] [US2] Add basis-run continuity assertions for linked coverage summaries, explicit no-basis-run fallback, and tenant-scoped operations fallback in `tests/Feature/Filament/InventoryPagesTest.php` and `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`
- [X] T017 [P] [US2] Add safe degradation assertions for viewers who can see inventory truth but cannot open the basis run in `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php` and `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`
### Implementation for User Story 2
- [X] T018 [P] [US2] Surface basis-run summary, continuity actions, no-sync fallback, and provider or permission follow-up guidance on the coverage report in `app/Filament/Pages/InventoryCoverage.php` and `resources/views/filament/pages/inventory-coverage.blade.php`
- [X] T019 [P] [US2] Surface basis-run continuity and safe link rendering in the inventory KPI summary using `app/Filament/Widgets/Inventory/InventoryKpiHeader.php` and `app/Support/Inventory/TenantCoverageTruthResolver.php`
- [X] T020 [US2] Run the focused continuity pack in `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`, `tests/Feature/Filament/InventoryPagesTest.php`, and `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php`
**Checkpoint**: Coverage claims are now auditable through the basis run and remain safe when the current user cannot open the run detail.
---
## Phase 5: User Story 3 - Diagnose Per-Type Inventory Sync Results Without Raw JSON (Priority: P2)
**Goal**: Make canonical inventory-sync run detail render human-readable per-type results and keep execution outcome separate from tenant coverage follow-up.
**Independent Test**: Seed an inventory-sync run with mixed per-type outcomes and verify the canonical run viewer shows a readable coverage section without relying on raw JSON.
### Tests for User Story 3
- [X] T021 [P] [US3] Add human-readable inventory-sync section assertions, including provider or permission follow-up guidance, in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` and `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T022 [P] [US3] Extend mixed-outcome payload expectations for per-type coverage rows in `tests/Feature/Inventory/InventorySyncServiceTest.php` and `tests/Feature/Inventory/RunInventorySyncJobTest.php`
### Implementation for User Story 3
- [X] T023 [US3] Add the inventory-sync per-type coverage enterprise-detail section with explicit next-step guidance in `app/Filament/Resources/OperationRunResource.php`
- [X] T024 [P] [US3] Create the human-readable per-type run coverage view with provider or permission follow-up cues in `resources/views/filament/infolists/entries/inventory-coverage-truth.blade.php`
- [X] T025 [US3] Run the focused inventory-sync run-detail pack in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Inventory/InventorySyncServiceTest.php`, and `tests/Feature/Inventory/RunInventorySyncJobTest.php`
**Checkpoint**: Inventory-sync run detail now explains per-type outcomes directly and no longer forces operators into raw JSON for core coverage truth.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Align copy, run focused verification, and confirm the feature stayed within the intended no-new-persistence boundary.
- [X] T026 [P] Align operator-facing coverage labels, restore or compare separation, and helper copy across `app/Filament/Widgets/Inventory/InventoryKpiHeader.php`, `app/Support/Inventory/InventoryKpiBadges.php`, `app/Filament/Pages/InventoryCoverage.php`, and `app/Filament/Resources/OperationRunResource.php`
- [X] T027 Run `vendor/bin/sail bin pint --dirty --format agent` for touched files under `app/`, `resources/views/`, and `tests/`
- [X] T028 Run the focused Sail verification pack from `specs/177-inventory-coverage-truth/quickstart.md` against `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php`, `tests/Unit/Badges/BadgeCatalogTest.php`, `tests/Unit/Badges/InventoryCoverageStateBadgesTest.php`, `tests/Feature/Filament/InventoryCoverageTableTest.php`, `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`, `tests/Feature/Filament/InventoryPagesTest.php`, `tests/Feature/Filament/InventoryItemResourceTest.php`, `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Inventory/InventorySyncServiceTest.php`, `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php`, `tests/Feature/Inventory/RunInventorySyncJobTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php`
- [X] T029 Validate that the final implementation introduces no schema migration, no inventory-sync backend rewrite, and no new persisted truth by reviewing `database/migrations/`, `app/Services/Inventory/InventorySyncService.php`, `app/Jobs/RunInventorySyncJob.php`, and `specs/177-inventory-coverage-truth/plan.md` against the final diff
- [X] T030 [P] Review `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php` and `app/Filament/Pages/InventoryCoverage.php` against `docs/product/standards/list-surface-review-checklist.md` before final sign-off
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user story work.
- **User Story 1 (Phase 3)**: Depends on Foundational completion and delivers the MVP truth-correction slice.
- **User Story 2 (Phase 4)**: Depends on User Story 1 because it extends the same summary surfaces with basis-run continuity and safe drill-through.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and can proceed in parallel with User Story 2 if staffed, since it focuses on canonical run detail.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: Depends only on the shared derived coverage contract from Phase 2 and is the recommended MVP.
- **User Story 2 (P1)**: Depends on User Story 1 because the same inventory summary surfaces must first speak truthful coverage before adding run continuity.
- **User Story 3 (P2)**: Depends only on the foundational contract and existing canonical run detail infrastructure; it can be delivered after User Story 1 or in parallel with User Story 2, but is lower priority.
### Within Each User Story
- Story tests should be written before or alongside implementation and should fail for the intended reason before the story is considered complete.
- Shared resolver or badge changes should land before surface refactors that consume them.
- Surface refactors should land before the focused story-level verification run.
### Parallel Opportunities
- `T001` and `T002` can run in parallel during Setup.
- `T003`, `T004`, `T005`, and `T006` can run in parallel during Foundational work.
- `T009` and `T010` can run in parallel for User Story 1.
- `T011` and `T012` can run in parallel for User Story 1 after the foundational resolver is ready.
- `T016` and `T017` can run in parallel for User Story 2.
- `T018` and `T019` can run in parallel for User Story 2 after basis-run continuity metadata is available.
- `T021` and `T022` can run in parallel for User Story 3.
- `T023` and `T024` can run in parallel for User Story 3.
- `T026` and `T030` can run in parallel during Polish.
---
## Parallel Example: User Story 1
```bash
# Launch the inventory truth regressions together before changing summary surfaces:
Task: T009 tests/Feature/Filament/InventoryPagesTest.php and tests/Feature/Filament/InventoryItemResourceTest.php
Task: T010 tests/Feature/Filament/InventoryCoverageTableTest.php and tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php
# Split KPI and helper refactors once the shared resolver is ready:
Task: T011 app/Filament/Widgets/Inventory/InventoryKpiHeader.php
Task: T012 app/Support/Inventory/InventoryKpiBadges.php
```
## Parallel Example: User Story 2
```bash
# Write the continuity and degradation assertions together before adding links:
Task: T016 tests/Feature/Filament/InventoryCoverageRunContinuityTest.php and tests/Feature/Filament/InventoryPagesTest.php
Task: T017 tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php and tests/Feature/Filament/InventoryCoverageRunContinuityTest.php
# Split page and widget continuity work after the tests exist:
Task: T018 app/Filament/Pages/InventoryCoverage.php and resources/views/filament/pages/inventory-coverage.blade.php
Task: T019 app/Filament/Widgets/Inventory/InventoryKpiHeader.php and app/Support/Inventory/TenantCoverageTruthResolver.php
```
## Parallel Example: User Story 3
```bash
# Lock the run-detail expectations and payload assertions together:
Task: T021 tests/Feature/Operations/TenantlessOperationRunViewerTest.php and tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Task: T022 tests/Feature/Inventory/InventorySyncServiceTest.php and tests/Feature/Inventory/RunInventorySyncJobTest.php
# Build the section view and enterprise-detail wiring in parallel:
Task: T023 app/Filament/Resources/OperationRunResource.php
Task: T024 resources/views/filament/infolists/entries/inventory-coverage-truth.blade.php
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate the inventory items list and coverage page with the User Story 1-focused subset of `specs/177-inventory-coverage-truth/quickstart.md`.
### Incremental Delivery
1. Finish Setup and Foundational work.
2. Deliver User Story 1 and validate truthful tenant coverage at a glance.
3. Deliver User Story 2 and validate run continuity plus safe degradation.
4. Deliver User Story 3 and validate human-readable inventory-sync run detail.
5. Finish with formatting, the focused Sail pack, and the no-new-persistence review.
### Parallel Team Strategy
1. One developer can complete Phase 1 and the model or resolver side of Phase 2 while another prepares the badge and feature regressions.
2. After Phase 2, one developer can take User Story 1 while another prepares User Story 3 run-detail tests.
3. After User Story 1 stabilizes, one developer can handle User Story 2 continuity while another completes User Story 3 UI wiring.
4. Rejoin for Phase 6 formatting and verification.
---
## Notes
- Every task follows the required checklist format: checkbox, task ID, optional parallel marker, required story label for story phases, and exact file paths.
- The suggested MVP scope is Phase 1 through Phase 3 only.
- No task in this plan introduces new persistence, a new Graph contract, a new Filament panel or provider registration change, or a new destructive action.

View File

@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\InventoryCoverage;
use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\Workspaces\WorkspaceContext;
pest()->browser()->timeout(15_000);
function inventoryItemListLivewireScript(string $body): string
{
return <<<JS
const component = window.Livewire?.all().find((entry) => {
if (entry.name === 'App\\Filament\\Resources\\InventoryItemResource\\Pages\\ListInventoryItems') {
return true;
}
const state = entry.canonical ?? {};
return Object.prototype.hasOwnProperty.call(state, 'tableFilters')
&& Object.prototype.hasOwnProperty.call(state, 'tableDeferredFilters')
&& Object.prototype.hasOwnProperty.call(state, 'tableRecordsPerPage')
&& Object.prototype.hasOwnProperty.call(state, 'tableSearch');
});
if (! component) {
throw new Error('ListInventoryItems Livewire component not found.');
}
{$body}
JS;
}
function seedSpec177InventoryCoverageTruthFixtures(Tenant $tenant): OperationRun
{
foreach (range(1, 130) as $index) {
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => sprintf('Browser Inventory %02d', $index),
'policy_type' => 'deviceConfiguration',
'external_id' => sprintf('browser-inventory-%02d', $index),
'platform' => 'windows',
'last_seen_at' => now()->subMinutes($index),
]);
}
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Browser Conditional Access',
'policy_type' => 'conditionalAccessPolicy',
'external_id' => 'browser-conditional-access',
'platform' => 'windows',
'last_seen_at' => now()->subMinutes(31),
]);
return createInventorySyncOperationRunWithCoverage(
$tenant,
[
'conditionalAccessPolicy' => 'succeeded',
'deviceConfiguration' => 'failed',
'roleScopeTag' => 'skipped',
],
['roleScopeTag'],
[
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now()->subMinute(),
],
);
}
function seedSpec177InventoryItemFilterPaginationFixtures(Tenant $tenant): void
{
foreach (range(1, 45) as $index) {
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => sprintf('Windows Fresh Device %02d', $index),
'policy_type' => 'deviceConfiguration',
'external_id' => sprintf('windows-fresh-device-%02d', $index),
'platform' => 'windows',
'last_seen_at' => now()->subMinutes($index),
]);
}
foreach (range(1, 3) as $index) {
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => sprintf('Mac Fresh Device %02d', $index),
'policy_type' => 'deviceConfiguration',
'external_id' => sprintf('mac-fresh-device-%02d', $index),
'platform' => 'macOS',
'last_seen_at' => now()->subMinutes(45 + $index),
]);
}
foreach (range(1, 3) as $index) {
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => sprintf('Conditional Access Fresh %02d', $index),
'policy_type' => 'conditionalAccessPolicy',
'external_id' => sprintf('conditional-access-fresh-%02d', $index),
'platform' => 'windows',
'last_seen_at' => now()->subMinutes(48 + $index),
]);
}
foreach (range(46, 55) as $index) {
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => sprintf('Windows Fresh Device %02d', $index),
'policy_type' => 'deviceConfiguration',
'external_id' => sprintf('windows-fresh-device-%02d', $index),
'platform' => 'windows',
'last_seen_at' => now()->subMinutes(6 + $index),
]);
}
foreach (range(1, 3) as $index) {
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => sprintf('Windows Stale Device %02d', $index),
'policy_type' => 'deviceConfiguration',
'external_id' => sprintf('windows-stale-device-%02d', $index),
'platform' => 'windows',
'last_seen_at' => now()->subDays(3)->subMinutes($index),
]);
}
}
it('smokes inventory coverage truth surfaces with filters, pagination, and run drill-through', function (): void {
$tenant = Tenant::factory()->create([
'name' => 'Spec177 Browser Tenant',
'external_id' => 'spec177-browser-tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = seedSpec177InventoryCoverageTruthFixtures($tenant);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$searchPage = visit(InventoryItemResource::getUrl('index', tenant: $tenant));
$searchPage
->waitForText('Inventory Items')
->assertNoJavaScriptErrors()
->assertSee('Covered types')
->assertSee('Need follow-up')
->assertSee('Coverage basis')
->assertSee('Open basis run')
->assertSee('Run Inventory Sync')
->assertSee('Browser Inventory 01')
->assertDontSee('Browser Inventory 130')
->fill('main input[placeholder="Search"]', 'Browser Inventory 01')
->waitForText('Browser Inventory 01')
->assertNoJavaScriptErrors()
->assertSee('Browser Inventory 01')
->assertDontSee('Browser Inventory 02');
$page = visit(InventoryCoverage::getUrl(tenant: $tenant));
$page
->waitForText('Tenant coverage truth')
->assertNoJavaScriptErrors()
->assertSee('Latest coverage-bearing sync completed')
->assertSee('Open basis run')
->assertSee('Open inventory items')
->fill('input[placeholder="Search by type or label"]', 'Conditional Access')
->waitForText('Conditional Access')
->assertNoJavaScriptErrors()
->assertSee('Conditional Access')
->assertScript("document.querySelector('input[placeholder=\"Search by type or label\"]')?.value === 'Conditional Access'", true)
->click('Open basis run')
->waitForText('Operation #'.(int) $run->getKey())
->assertNoJavaScriptErrors()
->assertRoute('admin.operations.view', ['run' => (int) $run->getKey()])
->assertSee('Inventory sync coverage')
->assertSee('Need follow-up');
$page->script(<<<'JS'
history.back();
JS);
$page
->waitForText('Tenant coverage truth')
->assertNoJavaScriptErrors()
->click('Open inventory items')
->waitForText('Inventory Items')
->assertNoJavaScriptErrors()
->assertSee('Browser Inventory 01');
});
it('smokes inventory item pagination with stable filter combinations', function (): void {
$tenant = Tenant::factory()->create([
'name' => 'Spec177 Browser Filter Tenant',
'external_id' => 'spec177-browser-filter-tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
seedSpec177InventoryItemFilterPaginationFixtures($tenant);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$page = visit(InventoryItemResource::getUrl('index', tenant: $tenant));
$page
->waitForText('Inventory Items')
->assertNoJavaScriptErrors()
->assertSee('Windows Fresh Device 01')
->assertDontSee('Windows Fresh Device 30')
->assertDontSee('Mac Fresh Device 01')
->wait(1);
$page->script(inventoryItemListLivewireScript(<<<'JS'
component.$wire.set('tableRecordsPerPage', 50);
JS));
$page
->wait(1)
->waitForText('Mac Fresh Device 01')
->assertNoJavaScriptErrors()
->assertSee('Mac Fresh Device 01')
->assertSee('Conditional Access Fresh 01')
->assertDontSee('Windows Fresh Device 46');
$page->script(inventoryItemListLivewireScript(<<<'JS'
component.$wire.set('tableDeferredFilters.policy_type.value', 'deviceConfiguration');
component.$wire.set('tableDeferredFilters.platform.value', 'windows');
component.$wire.set('tableDeferredFilters.stale.value', '0');
component.$wire.call('applyTableFilters');
JS));
$page
->wait(1)
->waitForText('Active filters')
->waitForText('Windows Fresh Device 46')
->assertNoJavaScriptErrors()
->assertSee('Policy type: Device Configuration')
->assertSee('Platform: Windows')
->assertSee('Freshness: Fresh')
->assertSee('Windows Fresh Device 46')
->assertDontSee('Mac Fresh Device 01')
->assertDontSee('Conditional Access Fresh 01')
->assertDontSee('Windows Stale Device 01');
$page->script(inventoryItemListLivewireScript(<<<'JS'
component.$wire.call('gotoPage', 2);
JS));
$page
->wait(1)
->waitForText('Windows Fresh Device 51')
->assertNoJavaScriptErrors()
->assertSee('Windows Fresh Device 51')
->assertDontSee('Windows Fresh Device 01')
->assertDontSee('Mac Fresh Device 01');
});

View File

@ -3,7 +3,11 @@
declare(strict_types=1);
use App\Filament\Pages\InventoryCoverage;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -17,6 +21,45 @@
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'inventory' => [
'coverage' => InventoryCoveragePayload::buildPayload([
'deviceConfiguration' => [
'status' => InventoryCoveragePayload::StatusFailed,
'item_count' => 0,
'error_code' => 'graph_forbidden',
],
], []),
],
],
'completed_at' => now(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'succeeded',
'context' => [
'inventory' => [
'coverage' => InventoryCoveragePayload::buildPayload([
'deviceConfiguration' => [
'status' => InventoryCoveragePayload::StatusSucceeded,
'item_count' => 1,
],
], []),
],
],
'completed_at' => now()->subMinute(),
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
@ -29,5 +72,10 @@
Livewire::actingAs($user)->test(InventoryCoverage::class)
->assertOk()
->assertSee('Coverage');
->assertSee('Tenant coverage truth')
->assertTableColumnFormattedStateSet(
'coverage_state',
BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label,
'policy:deviceConfiguration',
);
});

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\InventoryCoverage;
use App\Filament\Resources\InventoryItemResource;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function seedCoverageBasisRun(Tenant $tenant): OperationRun
{
return OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'inventory' => [
'coverage' => InventoryCoveragePayload::buildPayload([
'deviceConfiguration' => [
'status' => InventoryCoveragePayload::StatusFailed,
'item_count' => 0,
'error_code' => 'graph_forbidden',
],
], []),
],
],
'completed_at' => now(),
]);
}
it('shows the basis run and tenant-scoped history path on the coverage report for authorized viewers', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = seedCoverageBasisRun($tenant);
$historyUrl = route('admin.operations.index', [
'tenant_id' => (int) $tenant->getKey(),
'tableFilters' => [
'type' => [
'value' => 'inventory_sync',
],
],
]);
$this->actingAs($user)
->get(InventoryCoverage::getUrl(tenant: $tenant))
->assertOk()
->assertSee('Latest coverage-bearing sync completed')
->assertSee('Open basis run')
->assertSee(route('admin.operations.view', ['run' => (int) $run->getKey()]), false)
->assertSee($historyUrl, false)
->assertSee('Review the cited inventory sync to inspect provider or permission issues in detail.');
});
it('degrades basis-run links safely for viewers who cannot open inventory-sync runs', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
seedCoverageBasisRun($tenant);
$this->actingAs($user)
->get(InventoryCoverage::getUrl(tenant: $tenant))
->assertOk()
->assertSee('The coverage basis is current, but your role cannot open the cited run detail.')
->assertDontSee('Open basis run');
$this->actingAs($user)
->get(InventoryItemResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertSee('Latest run detail is not available with your current role.')
->assertDontSee('Open basis run');
});
it('keeps the no-basis fallback explicit on the inventory items list', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user)
->get(InventoryItemResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertSee('No current result')
->assertSee('Run Inventory Sync from Inventory Items to establish current coverage truth.');
});

View File

@ -3,19 +3,21 @@
declare(strict_types=1);
use App\Filament\Pages\InventoryCoverage;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use Filament\Facades\Filament;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function inventoryCoverageRecordKey(string $segment, string $type): string
{
return "{$segment}:{$type}";
@ -31,198 +33,147 @@ function inventoryCoverageComponent(User $user, Tenant $tenant): Testable
return Livewire::actingAs($user)->test(InventoryCoverage::class);
}
function removeInventoryCoverageRestoreMetadata(): void
function seedTruthfulCoverageRun(Tenant $tenant): OperationRun
{
config()->set(
'tenantpilot.supported_policy_types',
collect(config('tenantpilot.supported_policy_types', []))
->map(function (array $row): array {
unset($row['restore']);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Conditional Access Prod',
'policy_type' => 'conditionalAccessPolicy',
'external_id' => 'ca-1',
'platform' => 'windows',
]);
return $row;
})
->all(),
);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Compliance Legacy',
'policy_type' => 'deviceCompliancePolicy',
'external_id' => 'dc-1',
'platform' => 'windows',
]);
config()->set(
'tenantpilot.foundation_types',
collect(config('tenantpilot.foundation_types', []))
->map(function (array $row): array {
unset($row['restore']);
return $row;
})
->all(),
);
return OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'inventory' => [
'coverage' => InventoryCoveragePayload::buildPayload([
'conditionalAccessPolicy' => [
'status' => InventoryCoveragePayload::StatusSucceeded,
'item_count' => 1,
],
'deviceConfiguration' => [
'status' => InventoryCoveragePayload::StatusFailed,
'item_count' => 0,
'error_code' => 'graph_forbidden',
],
'roleScopeTag' => [
'status' => InventoryCoveragePayload::StatusSkipped,
'item_count' => 0,
],
], ['roleScopeTag']),
],
],
'completed_at' => now(),
]);
}
it('renders searchable coverage rows for policy and foundation metadata', function (): void {
it('renders truthful coverage states and deterministic follow-up order', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedTruthfulCoverageRun($tenant);
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
$deviceConfigurationKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
$scopeTagKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
$failedKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
$unknownKey = inventoryCoverageRecordKey('policy', 'deviceCompliancePolicy');
$skippedKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
$succeededKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
inventoryCoverageComponent($user, $tenant)
->assertOk()
->assertTableColumnExists('type')
->assertTableColumnExists('coverage_state')
->assertTableColumnExists('label')
->assertTableColumnExists('follow_up_guidance')
->assertTableColumnExists('observed_item_count')
->assertTableColumnExists('category')
->assertTableColumnExists('dependencies')
->assertCountTableRecords(
count(config('tenantpilot.supported_policy_types', [])) + count(config('tenantpilot.foundation_types', [])),
->assertCanSeeTableRecords([$failedKey, $unknownKey, $skippedKey], inOrder: true)
->assertTableColumnFormattedStateSet(
'coverage_state',
BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label,
$failedKey,
)
->assertCanSeeTableRecords([$conditionalAccessKey, $scopeTagKey])
->searchTable('conditional')
->assertCanSeeTableRecords([$conditionalAccessKey])
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $scopeTagKey])
->searchTable('Scope Tag')
->assertCanSeeTableRecords([$scopeTagKey])
->assertCanNotSeeTableRecords([$conditionalAccessKey])
->searchTable(null)
->assertCanSeeTableRecords([$conditionalAccessKey, $deviceConfigurationKey, $scopeTagKey]);
->assertTableColumnFormattedStateSet(
'coverage_state',
BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'unknown')->label,
$unknownKey,
)
->assertTableColumnFormattedStateSet(
'coverage_state',
BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'skipped')->label,
$skippedKey,
)
->assertTableColumnFormattedStateSet(
'coverage_state',
BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'succeeded')->label,
$succeededKey,
)
->assertTableColumnFormattedStateSet(
'follow_up_guidance',
'Review provider consent or permissions, then rerun inventory sync.',
$failedKey,
)
->assertTableColumnFormattedStateSet(
'follow_up_guidance',
'No current basis result exists for this type. Run inventory sync to confirm coverage.',
$unknownKey,
)
->assertTableColumnStateSet('observed_item_count', 1, $unknownKey)
->searchTable('Compliance')
->assertCanSeeTableRecords([$unknownKey])
->assertCanNotSeeTableRecords([$failedKey, $skippedKey, $succeededKey]);
});
it('sorts coverage rows by type and label deterministically', function (): void {
it('filters truthful coverage rows by state, category, and restore metadata', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedTruthfulCoverageRun($tenant);
$appProtectionKey = inventoryCoverageRecordKey('policy', 'appProtectionPolicy');
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
$deviceComplianceKey = inventoryCoverageRecordKey('policy', 'deviceCompliancePolicy');
$adminTemplatesKey = inventoryCoverageRecordKey('policy', 'groupPolicyConfiguration');
$appConfigDeviceKey = inventoryCoverageRecordKey('policy', 'managedDeviceAppConfiguration');
$appConfigMamKey = inventoryCoverageRecordKey('policy', 'mamAppConfiguration');
inventoryCoverageComponent($user, $tenant)
->sortTable('type')
->assertCanSeeTableRecords([$appProtectionKey, $conditionalAccessKey, $deviceComplianceKey], inOrder: true)
->sortTable('label')
->assertCanSeeTableRecords([$adminTemplatesKey, $appConfigDeviceKey, $appConfigMamKey], inOrder: true);
});
it('filters coverage rows by category and restore mode when restore metadata exists', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$assignmentFilterKey = inventoryCoverageRecordKey('foundation', 'assignmentFilter');
$scopeTagKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
$roleDefinitionKey = inventoryCoverageRecordKey('foundation', 'intuneRoleDefinition');
$roleAssignmentKey = inventoryCoverageRecordKey('foundation', 'intuneRoleAssignment');
$deviceConfigurationKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
$securityBaselineKey = inventoryCoverageRecordKey('policy', 'securityBaselinePolicy');
$failedKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
$unknownKey = inventoryCoverageRecordKey('policy', 'deviceCompliancePolicy');
$skippedKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
$succeededKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
inventoryCoverageComponent($user, $tenant)
->assertTableFilterExists('coverage_state')
->assertTableFilterExists('category')
->assertTableFilterExists('restore')
->filterTable('category', 'Foundations')
->assertCanSeeTableRecords([$assignmentFilterKey, $scopeTagKey])
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $conditionalAccessKey, $roleDefinitionKey, $roleAssignmentKey])
->filterTable('coverage_state', 'failed')
->assertCanSeeTableRecords([$failedKey])
->assertCanNotSeeTableRecords([$unknownKey, $skippedKey, $succeededKey])
->removeTableFilters()
->filterTable('category', 'RBAC')
->assertCanSeeTableRecords([$roleDefinitionKey, $roleAssignmentKey])
->assertCanNotSeeTableRecords([$assignmentFilterKey, $scopeTagKey, $deviceConfigurationKey])
->filterTable('category', 'Foundations')
->assertCanSeeTableRecords([$skippedKey])
->assertCanNotSeeTableRecords([$failedKey, $unknownKey, $succeededKey])
->removeTableFilters()
->filterTable('restore', 'preview-only')
->assertCanSeeTableRecords([$conditionalAccessKey, $securityBaselineKey, $roleDefinitionKey, $roleAssignmentKey])
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $assignmentFilterKey]);
->assertCanSeeTableRecords([$succeededKey])
->assertCanNotSeeTableRecords([$failedKey, $skippedKey]);
});
it('omits the restore filter when the runtime dataset has no restore metadata', function (): void {
removeInventoryCoverageRestoreMetadata();
it('shows a clear-filters empty state for the derived truth table', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$component = inventoryCoverageComponent($user, $tenant)
->assertTableFilterExists('category');
expect($component->instance()->getTable()->getFilter('restore'))->toBeNull();
});
it('shows a single clear-filters empty state action and can reset back to a populated dataset', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
seedTruthfulCoverageRun($tenant);
inventoryCoverageComponent($user, $tenant)
->assertTableEmptyStateActionsExistInOrder(['clear_filters'])
->searchTable('no-such-coverage-entry')
->assertCountTableRecords(0)
->assertSee('No coverage entries match this view')
->assertSee('Clear filters')
->searchTable(null)
->assertCountTableRecords(
count(config('tenantpilot.supported_policy_types', [])) + count(config('tenantpilot.foundation_types', [])),
)
->assertCanSeeTableRecords([$conditionalAccessKey]);
->assertSee('No coverage rows match this report')
->assertSee('Clear filters');
});
it('preserves badge semantics and dependency indicators in the interactive table columns', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
$deviceConfigurationKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
$assignmentFilterKey = inventoryCoverageRecordKey('foundation', 'assignmentFilter');
$typeSpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'conditionalAccessPolicy');
$categorySpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, 'Conditional Access');
$restoreSpec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, 'preview-only');
$riskSpec = BadgeCatalog::spec(BadgeDomain::PolicyRisk, 'high');
inventoryCoverageComponent($user, $tenant)
->assertTableColumnFormattedStateSet('label', $typeSpec->label, $conditionalAccessKey)
->assertTableColumnFormattedStateSet('category', $categorySpec->label, $conditionalAccessKey)
->assertTableColumnFormattedStateSet('restore', $restoreSpec->label, $conditionalAccessKey)
->assertTableColumnFormattedStateSet('risk', $riskSpec->label, $conditionalAccessKey)
->assertTableColumnFormattedStateSet('segment', 'Policy', $conditionalAccessKey)
->assertTableColumnFormattedStateSet('segment', 'Foundation', $assignmentFilterKey)
->assertTableColumnStateSet('dependencies', true, $deviceConfigurationKey)
->assertTableColumnStateSet('dependencies', false, $assignmentFilterKey)
->assertTableColumnExists('label', function (TextColumn $column) use ($typeSpec): bool {
$state = $column->getState();
return $column->isBadge()
&& $column->getColor($state) === $typeSpec->color
&& $column->getIcon($state) === $typeSpec->icon;
}, $conditionalAccessKey)
->assertTableColumnExists('category', function (TextColumn $column) use ($categorySpec): bool {
$state = $column->getState();
return $column->isBadge()
&& $column->getColor($state) === $categorySpec->color
&& $column->getIcon($state) === $categorySpec->icon;
}, $conditionalAccessKey)
->assertTableColumnExists('restore', function (TextColumn $column) use ($restoreSpec): bool {
$state = $column->getState();
return $column->isBadge()
&& $column->getColor($state) === $restoreSpec->color
&& $column->getIcon($state) === $restoreSpec->icon;
}, $conditionalAccessKey)
->assertTableColumnExists('risk', function (TextColumn $column) use ($riskSpec): bool {
$state = $column->getState();
return $column->isBadge()
&& $column->getColor($state) === $riskSpec->color
&& $column->getIcon($state) === $riskSpec->icon;
}, $conditionalAccessKey)
->assertTableColumnExists('dependencies', function (IconColumn $column): bool {
$state = $column->getState();
return $state === true
&& $column->getColor($state) === 'success'
&& (string) $column->getIcon($state) === 'heroicon-m-check-circle';
}, $deviceConfigurationKey)
->assertTableColumnExists('dependencies', function (IconColumn $column): bool {
$state = $column->getState();
return $state === false
&& $column->getColor($state) === 'gray'
&& (string) $column->getIcon($state) === 'heroicon-m-minus-circle';
}, $assignmentFilterKey);
});
it('returns 404 for non-members on the inventory coverage page even when RBAC foundations exist', function (): void {
it('returns 404 for non-members on the inventory coverage page even when basis truth exists', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
seedTruthfulCoverageRun($tenant);
$this->actingAs($owner);
$tenant->makeCurrent();

View File

@ -41,7 +41,7 @@
$this->get(InventoryCoverage::getUrl(tenant: $tenant))
->assertOk()
->assertSee('Searchable support matrix');
->assertSee('Tenant coverage truth');
});
Bus::assertNothingDispatched();

View File

@ -3,8 +3,10 @@
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Auth\UiTooltips;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
@ -68,3 +70,50 @@
->assertActionDisabled('run_inventory_sync')
->assertActionExists('run_inventory_sync', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
});
test('inventory items page shows truthful coverage stats instead of support-matrix wording', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Conditional Access Prod',
'policy_type' => 'conditionalAccessPolicy',
'external_id' => 'ca-1',
'platform' => 'windows',
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'inventory' => [
'coverage' => InventoryCoveragePayload::buildPayload([
'conditionalAccessPolicy' => [
'status' => InventoryCoveragePayload::StatusSucceeded,
'item_count' => 1,
],
'deviceConfiguration' => [
'status' => InventoryCoveragePayload::StatusFailed,
'item_count' => 0,
'error_code' => 'graph_forbidden',
],
], []),
],
],
'completed_at' => now(),
]);
$this->actingAs($user)
->get(InventoryItemResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertSee('Covered types')
->assertSee('Need follow-up')
->assertSee('Coverage basis')
->assertSee('Open basis run')
->assertDontSee('Last inventory sync')
->assertDontSee('Inventory ops');
});

View File

@ -1,61 +1,110 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\InventoryCoverage;
use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('inventory hub pages load for a tenant', function () {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
uses(RefreshDatabase::class);
function seedInventoryCoverageBasis(Tenant $tenant): OperationRun
{
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'display_name' => 'Item A',
'policy_type' => 'deviceConfiguration',
'external_id' => 'item-a',
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Conditional Access Prod',
'policy_type' => 'conditionalAccessPolicy',
'external_id' => 'ca-1',
'platform' => 'windows',
]);
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Device Compliance Legacy',
'policy_type' => 'deviceCompliancePolicy',
'external_id' => 'dc-1',
'platform' => 'windows',
]);
return OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'succeeded',
'context' => ['selection_hash' => str_repeat('a', 64)],
'outcome' => 'partially_succeeded',
'context' => [
'inventory' => [
'coverage' => InventoryCoveragePayload::buildPayload([
'conditionalAccessPolicy' => [
'status' => InventoryCoveragePayload::StatusSucceeded,
'item_count' => 1,
],
'deviceConfiguration' => [
'status' => InventoryCoveragePayload::StatusFailed,
'item_count' => 0,
'error_code' => 'graph_forbidden',
],
'roleScopeTag' => [
'status' => InventoryCoveragePayload::StatusSkipped,
'item_count' => 0,
],
], ['roleScopeTag']),
],
],
'completed_at' => now(),
]);
}
test('inventory hub pages render truthful coverage-first summaries and basis continuity', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$basisRun = seedInventoryCoverageBasis($tenant);
$itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant);
$coverageUrl = InventoryCoverage::getUrl(tenant: $tenant);
$kpiLabels = [
'Total items',
'Coverage',
'Last inventory sync',
'Active ops',
'Inventory ops',
];
$this->actingAs($user)
->get($itemsUrl)
->assertOk()
->assertSee('Run Inventory Sync')
->assertSee($kpiLabels)
->assertSee('Item A');
->assertSee('Total items')
->assertSee('Covered types')
->assertSee('Need follow-up')
->assertSee('Coverage basis')
->assertSee('Active ops')
->assertSee('Open basis run')
->assertSee(route('admin.operations.view', ['run' => (int) $basisRun->getKey()]), false)
->assertSee('Conditional Access Prod');
$this->actingAs($user)
->get($coverageUrl)
->assertOk()
->assertSee($kpiLabels)
->assertSee('Coverage')
->assertSee('Searchable support matrix')
->assertSee('Search by policy type or label')
->assertSee('Coverage rows')
->assertSee('Segment')
->assertSee('Dependencies');
->assertSee('Tenant coverage truth')
->assertSee('Covered types')
->assertSee('Need follow-up')
->assertSee('Observed items')
->assertSee('Inventory sync history')
->assertSee('Open inventory items')
->assertSee((string) InventoryPolicyTypeMeta::label('conditionalAccessPolicy'))
->assertSee((string) InventoryPolicyTypeMeta::label('deviceConfiguration'))
->assertSee((string) InventoryPolicyTypeMeta::label('deviceCompliancePolicy'))
->assertSee('Review provider consent or permissions, then rerun inventory sync.');
});
test('inventory coverage page makes the no-basis fallback explicit', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user)
->get(InventoryCoverage::getUrl(tenant: $tenant))
->assertOk()
->assertSee('No current coverage basis')
->assertSee('Run Inventory Sync from Inventory Items to establish current tenant coverage truth.')
->assertSee('Open inventory items');
});

View File

@ -8,6 +8,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
@ -475,3 +476,58 @@ function baselineCompareGapContext(array $overrides = []): array
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});
it('renders a human-readable inventory sync coverage section before technical context', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => [
'inventory' => [
'coverage' => InventoryCoveragePayload::buildPayload([
'conditionalAccessPolicy' => [
'status' => InventoryCoveragePayload::StatusSucceeded,
'item_count' => 1,
],
'deviceConfiguration' => [
'status' => InventoryCoveragePayload::StatusFailed,
'item_count' => 0,
'error_code' => 'graph_forbidden',
],
'roleScopeTag' => [
'status' => InventoryCoveragePayload::StatusSkipped,
'item_count' => 0,
],
], ['roleScopeTag']),
],
],
'completed_at' => now(),
]);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Inventory sync coverage')
->assertSee('Execution outcome stays separate from the per-type results below.')
->assertSee('Coverage truth below explains which types created the follow-up.')
->assertSee('deviceConfiguration')
->assertSee('roleScopeTag')
->assertSee('Review provider consent or permissions, then rerun inventory sync.')
->assertSee('Run inventory sync again with the required types selected.');
$pageText = visiblePageText($response);
$coveragePosition = mb_strpos($pageText, 'Inventory sync coverage');
$contextPosition = mb_strpos($pageText, 'Context');
expect($coveragePosition)->not->toBeFalse()
->and($contextPosition)->not->toBeFalse()
->and($coveragePosition)->toBeLessThan($contextPosition);
});

View File

@ -123,7 +123,7 @@ function spec125DetailPlatformContext(): PlatformUser
$table = spec125DetailTable($component);
expect($table->getDefaultSortColumn())->toBe('label');
expect($table->getDefaultSortColumn())->toBe('follow_up_priority');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::customPage());
expect($table->getColumn('type')?->isSortable())->toBeTrue();

View File

@ -134,11 +134,11 @@ function spec125BaselineTenantContext(): array
$table = spec125BaselineTable($component);
expect($table->getDefaultSortColumn())->toBe('label');
expect($table->getDefaultSortColumn())->toBe('follow_up_priority');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::customPage());
expect($table->getEmptyStateHeading())->toBe('No coverage entries match this view');
expect($table->getEmptyStateDescription())->toBe('Clear the current search or filters to return to the full coverage matrix.');
expect($table->getEmptyStateHeading())->toBe('No coverage rows match this report');
expect($table->getEmptyStateDescription())->toBe('Clear the current search or filters to return to the full tenant coverage report.');
expect($table->getColumn('type')?->isSortable())->toBeTrue();
expect($table->getColumn('label')?->isSortable())->toBeTrue();
expect($table->getColumn('dependencies')?->isToggleable())->toBeTrue();

View File

@ -319,6 +319,8 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array
expect($coverage['intuneRoleDefinition']['status'] ?? null)->toBe('succeeded');
expect($coverage['intuneRoleAssignment']['status'] ?? null)->toBe('succeeded');
expect($coverage['intuneRoleDefinition']['item_count'] ?? null)->toBe(1);
expect($coverage['intuneRoleAssignment']['item_count'] ?? null)->toBe(1);
});
test('inventory sync does not sync foundation types when include_foundations is false', function () {

View File

@ -75,7 +75,7 @@
$policyTypes = is_array($policyTypes) ? array_values(array_filter(array_map('strval', $policyTypes))) : [];
foreach ($policyTypes as $policyType) {
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null);
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null, 1);
}
return [
@ -101,4 +101,6 @@
expect($coverage)->toBeArray();
expect(array_keys($coverage))->toEqualCanonicalizing($policyTypes);
expect(array_values(collect($coverage)->map(fn (array $row): int => (int) ($row['item_count'] ?? 0))->all()))
->toBe(array_fill(0, count($policyTypes), 1));
});

View File

@ -26,19 +26,25 @@
$mockSync
->shouldReceive('executeSelection')
->once()
->andReturn([
'status' => 'success',
'had_errors' => false,
'error_codes' => [],
'error_context' => [],
'errors_count' => 0,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'skipped_policy_types' => [],
'processed_policy_types' => $computed['selection']['policy_types'],
'failed_policy_types' => [],
'selection_hash' => $computed['selection_hash'],
]);
->andReturnUsing(function (OperationRun $operationRun, $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed) use ($computed): array {
foreach ($computed['selection']['policy_types'] as $policyType) {
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null, 1);
}
return [
'status' => 'success',
'had_errors' => false,
'error_codes' => [],
'error_context' => [],
'errors_count' => 0,
'items_observed_count' => count($computed['selection']['policy_types']),
'items_upserted_count' => count($computed['selection']['policy_types']),
'skipped_policy_types' => [],
'processed_policy_types' => $computed['selection']['policy_types'],
'failed_policy_types' => [],
'selection_hash' => $computed['selection_hash'],
];
});
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
@ -65,6 +71,7 @@
$context = is_array($opRun->context) ? $opRun->context : [];
expect($context)->toHaveKey('result');
expect($context['result']['had_errors'] ?? null)->toBeFalse();
expect($context['inventory']['coverage']['policy_types'][array_values($policyTypes)[0]]['item_count'] ?? null)->toBe(1);
$counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : [];
expect((int) ($counts['total'] ?? 0))->toBe(count($policyTypes));
@ -133,6 +140,7 @@
$context = is_array($opRun->context) ? $opRun->context : [];
expect($context)->toHaveKey('result');
expect($context['result']['had_errors'] ?? null)->toBeTrue();
expect($context['inventory']['coverage']['policy_types'][array_values($policyTypes)[0]]['error_code'] ?? null)->toBe('locked');
$counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : [];
expect((int) ($counts['processed'] ?? 0))->toBe(count($policyTypes));

View File

@ -7,6 +7,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
@ -658,3 +659,45 @@
'succeeded runs stay stable' => OperationRunOutcome::Succeeded->value,
'failed runs stay stable' => OperationRunOutcome::Failed->value,
]);
it('renders human-readable per-type inventory coverage in the canonical tenantless viewer', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => [
'inventory' => [
'coverage' => InventoryCoveragePayload::buildPayload([
'conditionalAccessPolicy' => [
'status' => InventoryCoveragePayload::StatusSucceeded,
'item_count' => 1,
],
'deviceConfiguration' => [
'status' => InventoryCoveragePayload::StatusFailed,
'item_count' => 0,
'error_code' => 'graph_forbidden',
],
], []),
],
],
'completed_at' => now(),
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertSuccessful()
->assertSee('Inventory sync coverage')
->assertSee('Execution outcome stays separate from the per-type results below.')
->assertSee('deviceConfiguration')
->assertSee('Review provider consent or permissions, then rerun inventory sync.');
});

View File

@ -5,8 +5,10 @@
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
@ -158,4 +160,36 @@
->assertCanSeeTableRecords([$tenantBRecord])
->assertCanNotSeeTableRecords([$tenantARecord]);
});
it('shows coverage truth without a basis-run link for readonly members missing inventory-sync capability', function () {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'inventory' => [
'coverage' => InventoryCoveragePayload::buildPayload([
'deviceConfiguration' => [
'status' => InventoryCoveragePayload::StatusFailed,
'item_count' => 0,
'error_code' => 'graph_forbidden',
],
], []),
],
],
'completed_at' => now(),
]);
$this->actingAs($user)
->get(InventoryItemResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertSee('Coverage basis')
->assertSee('Latest run detail is not available with your current role.')
->assertDontSee('Open basis run');
});
});

View File

@ -34,3 +34,8 @@
->and($spec->classification)->toBe(OperatorStateClassification::Diagnostic)
->and($spec->diagnosticLabel)->toBe('Fallback renderer');
});
it('registers inventory coverage state badge mappings centrally', function (): void {
expect(BadgeCatalog::mapper(BadgeDomain::InventoryCoverageState))->not->toBeNull()
->and(BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label)->toBe('Failed');
});

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps inventory coverage states to shared badge specs', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'succeeded'))
->label->toBe('Succeeded')
->color->toBe('success')
->icon->toBe('heroicon-m-check-circle');
expect(BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed'))
->label->toBe('Failed')
->color->toBe('danger')
->icon->toBe('heroicon-m-x-circle');
expect(BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'skipped'))
->label->toBe('Skipped')
->color->toBe('warning')
->icon->toBe('heroicon-m-minus-circle');
expect(BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'unknown'))
->label->toBe('Unknown')
->color->toBe('gray')
->icon->toBe('heroicon-m-question-mark-circle');
});
it('falls back safely for unsupported inventory coverage states', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'not-real'))
->label->toBe('Unknown')
->color->toBe('gray');
});

View File

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\Inventory\TenantCoverageTypeTruth;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('selects the latest completed coverage-bearing inventory sync run for tenant truth', function (): void {
$tenant = Tenant::factory()->create();
$olderBasis = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => ['status' => 'succeeded', 'item_count' => 1],
], attributes: [
'completed_at' => now()->subHours(2),
'outcome' => 'succeeded',
]);
createInventorySyncOperationRun($tenant, [
'status' => 'completed',
'outcome' => 'failed',
'completed_at' => now()->subMinute(),
]);
$latestCoverageBearing = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => ['status' => 'failed', 'item_count' => 2, 'error_code' => 'graph_forbidden'],
], attributes: [
'completed_at' => now()->subMinutes(5),
'outcome' => 'failed',
]);
expect(OperationRun::latestCompletedCoverageBearingInventorySyncForTenant((int) $tenant->getKey())?->is($latestCoverageBearing))
->toBeTrue()
->and(OperationRun::latestCompletedCoverageBearingInventorySyncForTenant((int) $tenant->getKey())?->is($olderBasis))
->toBeFalse();
$truth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
expect($truth->basisRunId())->toBe((int) $latestCoverageBearing->getKey())
->and($truth->basisRunOutcome())->toBe('failed')
->and($truth->hasCurrentCoverageResult)->toBeTrue();
});
it('derives per-type coverage truth, observed counts, and deterministic follow-up order', function (): void {
$tenant = Tenant::factory()->create();
InventoryItem::factory()->count(3)->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => 'deviceCompliancePolicy',
]);
InventoryItem::factory()->count(2)->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => 'conditionalAccessPolicy',
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => 'deviceConfiguration',
]);
createInventorySyncOperationRunWithCoverage($tenant, [
'deviceCompliancePolicy' => [
'status' => 'failed',
'item_count' => 3,
'error_code' => 'graph_forbidden',
],
'deviceConfiguration' => [
'status' => 'succeeded',
'item_count' => 1,
],
'roleScopeTag' => [
'status' => 'skipped',
'item_count' => 0,
],
], foundationTypes: ['roleScopeTag'], attributes: [
'completed_at' => now(),
'outcome' => 'partially_succeeded',
]);
$truth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
$rowsByType = collect($truth->rows)->keyBy(
static fn (TenantCoverageTypeTruth $row): string => $row->type,
);
expect($truth->supportedTypeCount)->toBeGreaterThanOrEqual(4)
->and($truth->succeededTypeCount)->toBe(1)
->and($truth->failedTypeCount)->toBe(1)
->and($truth->skippedTypeCount)->toBe(1)
->and($truth->unknownTypeCount)->toBe($truth->supportedTypeCount - 3)
->and($truth->followUpTypeCount)->toBe($truth->supportedTypeCount - 1)
->and($truth->observedItemTotal)->toBe(6);
expect($rowsByType['deviceCompliancePolicy'])
->coverageState->toBe('failed')
->basisErrorCode->toBe('graph_forbidden')
->basisItemCount->toBe(3)
->followUpGuidance->toBe('Review provider consent or permissions, then rerun inventory sync.');
expect($rowsByType['conditionalAccessPolicy'])
->coverageState->toBe('unknown')
->observedItemCount->toBe(2)
->followUpGuidance->toBe('No current basis result exists for this type. Run inventory sync to confirm coverage.');
expect($rowsByType['deviceConfiguration'])
->coverageState->toBe('succeeded')
->basisItemCount->toBe(1)
->followUpRequired->toBeFalse();
expect($rowsByType['roleScopeTag'])
->coverageState->toBe('skipped')
->segment->toBe('foundation')
->followUpGuidance->toBe('Run inventory sync again with the required types selected.');
$orderedTypes = array_map(
static fn (TenantCoverageTypeTruth $row): string => $row->type,
$truth->rows,
);
expect(array_search('deviceCompliancePolicy', $orderedTypes, true))->toBeLessThan(array_search('conditionalAccessPolicy', $orderedTypes, true))
->and(array_search('conditionalAccessPolicy', $orderedTypes, true))->toBeLessThan(array_search('roleScopeTag', $orderedTypes, true))
->and($truth->topPriorityFollowUpRow()?->type)->toBe('deviceCompliancePolicy');
});
it('returns unknown coverage rows when no basis run exists', function (): void {
$tenant = Tenant::factory()->create();
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => 'deviceConfiguration',
]);
$truth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
expect($truth->hasCurrentCoverageResult)->toBeFalse()
->and($truth->basisRunId())->toBeNull()
->and($truth->succeededTypeCount)->toBe(0)
->and($truth->failedTypeCount)->toBe(0)
->and($truth->skippedTypeCount)->toBe(0)
->and($truth->unknownTypeCount)->toBe($truth->supportedTypeCount)
->and($truth->followUpTypeCount)->toBe($truth->supportedTypeCount)
->and($truth->observedItemTotal)->toBe(1)
->and($truth->topPriorityFollowUpRow())->not->toBeNull();
});