Compare commits

...

3 Commits

Author SHA1 Message Date
1142d283eb feat: Spec 178 — Operations Lifecycle Alignment & Cross-Surface Truth Consistency (#209)
## Spec 178 — Operations Lifecycle Alignment & Cross-Surface Truth Consistency

Härtet die Run-Lifecycle-Wahrheit und Cross-Surface-Konsistenz über alle zentralen Operator-Flächen hinweg.

### Kern-Änderungen

**Lifecycle Truth Alignment**
- Einheitliche stale/stuck-Semantik zwischen Tenant-, Workspace-, Admin- und System-Surfaces
- `OperationRunFreshnessState` wird konsistent über alle Widgets und Seiten propagiert
- Gemeinsame Problem-Klassen-Trennung: `terminal_follow_up` vs. `active_stale_attention`

**BulkOperationProgress Freshness**
- Overlay zeigt nur noch `healthyActive()` Runs statt alle aktiven Runs
- Likely-stale Runs halten das Polling nicht mehr künstlich aktiv
- Terminal Runs verschwinden zeitnah aus dem Progress-Overlay

**Decision Zone im Run Detail**
- Stale/reconciled Attention in der primären Decision-Hierarchie
- Klare Antworten: aktiv? stale? reconciled? nächster Schritt?
- Artifact-reiche Runs behalten Lifecycle-Truth vor Deep-Diagnostics

**Cross-Surface Link-Continuity**
- Dashboard → Operations Hub → Run Detail erzählen dieselbe Geschichte
- Notifications referenzieren korrekte Problem-Klasse
- Workspace/Tenant-Attention verlinken problemklassengerecht

**System-Plane Fixes**
- `/system/ops/failures` 500-Error behoben (panel-sichere Artifact-URLs)
- System-Stuck/Failures zeigen reconciled stale lineage

### Weitere Fixes
- Inventory auth guard bereinigt (Gate statt ad-hoc Facades)
- Browser-Smoke-Tests stabilisiert (DOM-Assertions statt fragile Klicks)
- Test-Assertion-Drift für Verification/Lifecycle-Texte korrigiert

### Test-Ergebnis
Full Suite: **3269 passed**, 8 skipped, 0 failed

### Spec-Artefakte
- `specs/178-ops-truth-alignment/spec.md`
- `specs/178-ops-truth-alignment/plan.md`
- `specs/178-ops-truth-alignment/tasks.md`
- `specs/178-ops-truth-alignment/research.md`
- `specs/178-ops-truth-alignment/data-model.md`
- `specs/178-ops-truth-alignment/quickstart.md`
- `specs/178-ops-truth-alignment/contracts/operations-truth-alignment.openapi.yaml`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #209
2026-04-05 22:42:24 +00:00
f52d52540c feat: implement inventory coverage truth (#208)
## Summary
- implement Spec 177 inventory coverage truth across resolver, badges, KPIs, coverage page, and operation run detail surfaces
- add repo-native spec artifacts for the feature under `specs/177-inventory-coverage-truth`
- add unit, feature, and browser coverage for truth derivation, continuity, and inventory item filter/pagination smoke paths

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- focused Spec 177 browser smoke file passed with 2 tests / 57 assertions
- extended inventory-focused test pack passed with 52 tests / 434 assertions

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #208
2026-04-05 12:35:20 +00:00
dc46c4fa58 feat: complete provider truth cleanup (#207)
## Summary
- implement Spec 179 to make tenant lifecycle, provider consent, and provider verification the primary truth axes on the targeted Filament surfaces
- demote legacy tenant app status and legacy provider status and health to diagnostic-only roles, add centralized badge mappings for provider consent and verification, and keep provider connections excluded from global search
- add the full Spec 179 artifact set under `specs/179-provider-truth-cleanup/` plus focused Pest coverage for tenant truth cleanup, provider truth cleanup, RBAC, discovery safety, and badge semantics
- fix the numeric out-of-scope tenant route regression so inaccessible `/admin/tenants/{id}` paths return `404 Not Found` instead of `500`

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/RequiredFiltersTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantResourceAuthorizationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantScopingTest.php`
- `vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php`
- `vendor/bin/sail artisan test --compact tests/Unit/Badges/ProviderConnectionBadgesTest.php`

## Manual validation
- integrated-browser smoke on `/admin/tenants`, tenant detail, `/admin/provider-connections`, provider detail, and provider edit
- verified out-of-scope tenant and provider URLs return `404 Not Found` with the current session

## Notes
- branch: `179-provider-truth-cleanup`
- commit: `e54c6632`
- target: `dev`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #207
2026-04-05 00:48:31 +00:00
122 changed files with 8873 additions and 770 deletions

View File

@ -129,6 +129,12 @@ ## Active Technologies
- PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned (174-evidence-freshness-publication-trust) - PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned (174-evidence-freshness-publication-trust)
- 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 (175-workspace-governance-attention) - 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 (175-workspace-governance-attention)
- PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced (175-workspace-governance-attention) - 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 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages (178-ops-truth-alignment)
- PostgreSQL unchanged; existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change (178-ops-truth-alignment)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -148,8 +154,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 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 - 178-ops-truth-alignment: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages
- 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 - 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
- 173-tenant-dashboard-truth-alignment: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, `RecentDriftFindings`, `RecentOperations`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `FindingResource`, `OperationRunLinks`, and canonical admin Operations page - 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
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -6,19 +6,20 @@
use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeCatalog; use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -56,6 +57,8 @@ class InventoryCoverage extends Page implements HasTable
protected string $view = 'filament.pages.inventory-coverage'; protected string $view = 'filament.pages.inventory-coverage';
protected ?TenantCoverageTruth $cachedCoverageTruth = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
@ -67,7 +70,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.') ->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::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk 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 public static function shouldRegisterNavigation(): bool
@ -110,9 +113,12 @@ protected function getHeaderWidgets(): array
public function table(Table $table): Table public function table(Table $table): Table
{ {
return $table return $table
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->searchable() ->searchable()
->searchPlaceholder('Search by policy type or label') ->searchPlaceholder('Search by type or label')
->defaultSort('label') ->defaultSort('follow_up_priority')
->defaultPaginationPageOption(50) ->defaultPaginationPageOption(50)
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage()) ->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->records(function ( ->records(function (
@ -142,14 +148,16 @@ public function table(Table $table): Table
); );
}) })
->columns([ ->columns([
TextColumn::make('type') TextColumn::make('coverage_state')
->label('Type') ->label('Coverage state')
->sortable() ->badge()
->fontFamily(FontFamily::Mono) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventoryCoverageState))
->copyable() ->color(BadgeRenderer::color(BadgeDomain::InventoryCoverageState))
->wrap(), ->icon(BadgeRenderer::icon(BadgeDomain::InventoryCoverageState))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventoryCoverageState))
->sortable(),
TextColumn::make('label') TextColumn::make('label')
->label('Label') ->label('Type')
->sortable() ->sortable()
->badge() ->badge()
->formatStateUsing(function (?string $state, array $record): string { ->formatStateUsing(function (?string $state, array $record): string {
@ -179,17 +187,29 @@ public function table(Table $table): Table
return $spec->iconColor ?? $spec->color; return $spec->iconColor ?? $spec->color;
}) })
->wrap(), ->wrap(),
TextColumn::make('risk') TextColumn::make('follow_up_guidance')
->label('Risk') ->label('Follow-up guidance')
->wrap()
->toggleable(),
TextColumn::make('observed_item_count')
->label('Observed items')
->numeric()
->sortable(),
TextColumn::make('category')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk)) ->formatStateUsing(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->label)
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk)) ->color(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->color)
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk)) ->icon(fn (?string $state): ?string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->icon)
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)), ->iconColor(function (?string $state): ?string {
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state);
return $spec->iconColor ?? $spec->color;
})
->toggleable()
->wrap(),
TextColumn::make('restore') TextColumn::make('restore')
->label('Restore') ->label('Restore')
->badge() ->badge()
->state(fn (array $record): ?string => $record['restore'])
->formatStateUsing(function (?string $state): string { ->formatStateUsing(function (?string $state): string {
return filled($state) return filled($state)
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label ? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
@ -213,20 +233,7 @@ public function table(Table $table): Table
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state); $spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
return $spec->iconColor ?? $spec->color; 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(), ->toggleable(),
IconColumn::make('dependencies') IconColumn::make('dependencies')
->label('Dependencies') ->label('Dependencies')
@ -237,10 +244,31 @@ public function table(Table $table): Table
->falseColor('gray') ->falseColor('gray')
->alignCenter() ->alignCenter()
->toggleable(), ->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()) ->filters($this->tableFilters())
->emptyStateHeading('No coverage entries match this view') ->emptyStateHeading('No coverage rows match this report')
->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.') ->emptyStateDescription('Clear the current search or filters to return to the full tenant coverage report.')
->emptyStateIcon('heroicon-o-funnel') ->emptyStateIcon('heroicon-o-funnel')
->emptyStateActions([ ->emptyStateActions([
Action::make('clear_filters') Action::make('clear_filters')
@ -261,6 +289,14 @@ public function table(Table $table): Table
protected function tableFilters(): array protected function tableFilters(): array
{ {
$filters = [ $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') SelectFilter::make('category')
->label('Category') ->label('Category')
->options($this->categoryFilterOptions()), ->options($this->categoryFilterOptions()),
@ -279,84 +315,36 @@ protected function tableFilters(): array
* @return Collection<string, array{ * @return Collection<string, array{
* __key: string, * __key: string,
* key: string, * key: string,
* segment: string,
* type: string, * type: string,
* segment: string,
* label: string, * label: string,
* category: 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, * restore: ?string,
* risk: string, * risk: ?string,
* source_order: int * dependencies: bool,
* is_basis_payload_backed: bool
* }> * }>
*/ */
protected function coverageRows(): Collection protected function coverageRows(): Collection
{ {
$resolver = app(CoverageCapabilitiesResolver::class); $truth = $this->coverageTruth();
$supported = $this->mapCoverageRows( if (! $truth instanceof TenantCoverageTruth) {
rows: InventoryPolicyTypeMeta::supported(), return collect();
segment: 'policy', }
sourceOrderOffset: 0,
resolver: $resolver,
);
return $supported->merge($this->mapCoverageRows( return collect($truth->rows)
rows: InventoryPolicyTypeMeta::foundations(), ->mapWithKeys(static fn ($row): array => [
segment: 'foundation', $row->key => $row->toArray(),
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,
],
];
});
} }
/** /**
@ -367,6 +355,7 @@ protected function mapCoverageRows(
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
{ {
$normalizedSearch = Str::lower(trim((string) $search)); $normalizedSearch = Str::lower(trim((string) $search));
$coverageState = $filters['coverage_state']['value'] ?? null;
$category = $filters['category']['value'] ?? null; $category = $filters['category']['value'] ?? null;
$restore = $filters['restore']['value'] ?? null; $restore = $filters['restore']['value'] ?? null;
@ -380,6 +369,10 @@ function (Collection $rows) use ($normalizedSearch): Collection {
}); });
}, },
) )
->when(
filled($coverageState),
fn (Collection $rows): Collection => $rows->where('coverage_state', (string) $coverageState),
)
->when( ->when(
filled($category), filled($category),
fn (Collection $rows): Collection => $rows->where('category', (string) $category), fn (Collection $rows): Collection => $rows->where('category', (string) $category),
@ -396,22 +389,35 @@ function (Collection $rows) use ($normalizedSearch): Collection {
*/ */
protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): 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) { if ($sortColumn === null) {
return $rows->sortBy('source_order'); return $rows;
} }
$records = $rows->all(); $records = $rows->all();
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int { uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
$comparison = strnatcasecmp( $comparison = match ($sortColumn) {
(string) ($left[$sortColumn] ?? ''), 'observed_item_count' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
(string) ($right[$sortColumn] ?? ''), '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) { 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; return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
@ -468,4 +474,99 @@ protected function restoreFilterOptions(): array
}) })
->all(); ->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 && $user->can('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

@ -233,6 +233,8 @@ private function applyActiveTab(Builder $query): Builder
{ {
return match ($this->activeTab) { return match ($this->activeTab) {
'active' => $query->healthyActive(), 'active' => $query->healthyActive(),
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => $query->activeStaleAttention(),
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $query->terminalFollowUp(),
'blocked' => $query->dashboardNeedsFollowUp(), 'blocked' => $query->dashboardNeedsFollowUp(),
'succeeded' => $query 'succeeded' => $query
->where('status', OperationRunStatus::Completed->value) ->where('status', OperationRunStatus::Completed->value)
@ -281,9 +283,29 @@ private function applyRequestedDashboardPrefilter(): void
} }
} }
$requestedProblemClass = request()->query('problemClass');
if (in_array($requestedProblemClass, [
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
], true)) {
$this->activeTab = (string) $requestedProblemClass;
return;
}
$requestedTab = request()->query('activeTab'); $requestedTab = request()->query('activeTab');
if (in_array($requestedTab, ['all', 'active', 'blocked', 'succeeded', 'partial', 'failed'], true)) { if (in_array($requestedTab, [
'all',
'active',
'blocked',
'succeeded',
'partial',
'failed',
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
], true)) {
$this->activeTab = (string) $requestedTab; $this->activeTab = (string) $requestedTab;
} }
} }

View File

@ -16,6 +16,9 @@
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets; use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles; 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\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedNavigationResolver; use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperateHub\OperateHubShell; 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)) { if (VerificationReportViewer::shouldRenderForRun($record)) {
$builder->addSection( $builder->addSection(
$factory->viewSection( $factory->viewSection(
@ -766,7 +784,7 @@ private static function artifactTruthFact(
private static function decisionAttentionNote(OperationRun $record): ?string private static function decisionAttentionNote(OperationRun $record): ?string
{ {
return null; return OperationUxPresenter::decisionAttentionNote($record);
} }
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
@ -1169,6 +1187,106 @@ private static function reconciliationPayload(OperationRun $record): array
return $reconciliation; 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 private static function formatDetailTimestamp(mixed $value): string
{ {
if (! $value instanceof \Illuminate\Support\Carbon) { if (! $value instanceof \Illuminate\Support\Carbon) {

View File

@ -469,29 +469,12 @@ private static function migrationReviewDescription(?ProviderConnection $record):
private static function consentStatusLabelFromState(mixed $state): string private static function consentStatusLabelFromState(mixed $state): string
{ {
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown'); return BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $state)->label;
return match ($value) {
'required' => 'Required',
'granted' => 'Granted',
'failed' => 'Failed',
'revoked' => 'Revoked',
default => 'Unknown',
};
} }
private static function verificationStatusLabelFromState(mixed $state): string private static function verificationStatusLabelFromState(mixed $state): string
{ {
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown'); return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
return match ($value) {
'pending' => 'Pending',
'healthy' => 'Healthy',
'degraded' => 'Degraded',
'blocked' => 'Blocked',
'error' => 'Error',
default => 'Unknown',
};
} }
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
@ -527,7 +510,7 @@ public static function form(Schema $schema): Schema
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make('Status') Section::make('Current state')
->schema([ ->schema([
Placeholder::make('consent_status_display') Placeholder::make('consent_status_display')
->label('Consent') ->label('Consent')
@ -535,18 +518,31 @@ public static function form(Schema $schema): Schema
Placeholder::make('verification_status_display') Placeholder::make('verification_status_display')
->label('Verification') ->label('Verification')
->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)), ->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)),
TextInput::make('status') Placeholder::make('last_health_check_at_display')
->label('Status') ->label('Last check')
->disabled() ->content(fn (?ProviderConnection $record): string => $record?->last_health_check_at?->diffForHumans() ?? 'Never'),
->dehydrated(false), ])
TextInput::make('health_status') ->columns(2)
->label('Health') ->columnSpanFull(),
->disabled() Section::make('Diagnostics')
->dehydrated(false), ->schema([
Placeholder::make('status_display')
->label('Legacy status')
->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionStatus, $record?->status)->label),
Placeholder::make('health_status_display')
->label('Legacy health')
->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionHealth, $record?->health_status)->label),
Placeholder::make('migration_review_status_display') Placeholder::make('migration_review_status_display')
->label('Migration review') ->label('Migration review')
->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record)) ->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record))
->hint(fn (?ProviderConnection $record): ?string => static::migrationReviewDescription($record)), ->hint(fn (?ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
Placeholder::make('last_error_reason_code_display')
->label('Last error reason')
->content(fn (?ProviderConnection $record): string => filled($record?->last_error_reason_code) ? (string) $record->last_error_reason_code : 'n/a'),
Placeholder::make('last_error_message_display')
->label('Last error message')
->content(fn (?ProviderConnection $record): string => static::sanitizeErrorMessage($record?->last_error_message) ?? 'n/a')
->columnSpanFull(),
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
@ -580,22 +576,55 @@ public static function infolist(Schema $schema): Schema
->state(fn (ProviderConnection $record): string => static::credentialSourceLabel($record)), ->state(fn (ProviderConnection $record): string => static::credentialSourceLabel($record)),
]) ])
->columns(2), ->columns(2),
Section::make('Status') Section::make('Current state')
->schema([ ->schema([
Infolists\Components\TextEntry::make('consent_status') Infolists\Components\TextEntry::make('consent_status')
->label('Consent') ->label('Consent')
->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state)), ->badge()
->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state))
->color(BadgeRenderer::color(BadgeDomain::ProviderConsentStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)),
Infolists\Components\TextEntry::make('verification_status') Infolists\Components\TextEntry::make('verification_status')
->label('Verification') ->label('Verification')
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state)), ->badge()
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state))
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
Infolists\Components\TextEntry::make('last_health_check_at')
->label('Last check')
->since(),
])
->columns(2),
Section::make('Diagnostics')
->schema([
Infolists\Components\TextEntry::make('status') Infolists\Components\TextEntry::make('status')
->label('Status'), ->label('Legacy status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
Infolists\Components\TextEntry::make('health_status') Infolists\Components\TextEntry::make('health_status')
->label('Health'), ->label('Legacy health')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
Infolists\Components\TextEntry::make('migration_review_required') Infolists\Components\TextEntry::make('migration_review_required')
->label('Migration review') ->label('Migration review')
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record)) ->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)), ->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
Infolists\Components\TextEntry::make('last_error_reason_code')
->label('Last error reason')
->placeholder('n/a'),
Infolists\Components\TextEntry::make('last_error_message')
->label('Last error message')
->formatStateUsing(fn (?string $state): ?string => static::sanitizeErrorMessage($state))
->placeholder('n/a')
->columnSpanFull(),
]) ])
->columns(2), ->columns(2),
]); ]);
@ -655,20 +684,36 @@ public static function table(Table $table): Table
? 'Dedicated' ? 'Dedicated'
: 'Platform') : 'Platform')
->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'), ->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'),
Tables\Columns\TextColumn::make('consent_status')
->label('Consent')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConsentStatus))
->color(BadgeRenderer::color(BadgeDomain::ProviderConsentStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)),
Tables\Columns\TextColumn::make('verification_status')
->label('Verification')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderVerificationStatus))
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->label('Status') ->label('Legacy status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus)) ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('health_status') Tables\Columns\TextColumn::make('health_status')
->label('Health') ->label('Legacy health')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth)) ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('migration_review_required') Tables\Columns\TextColumn::make('migration_review_required')
->label('Migration review') ->label('Migration review')
->badge() ->badge()
@ -714,8 +759,45 @@ public static function table(Table $table): Table
return $query->where('provider_connections.provider', $value); return $query->where('provider_connections.provider', $value);
}), }),
SelectFilter::make('consent_status')
->label('Consent')
->options([
'unknown' => 'Unknown',
'required' => 'Required',
'granted' => 'Granted',
'failed' => 'Failed',
'revoked' => 'Revoked',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '') {
return $query;
}
return $query->where('provider_connections.consent_status', $value);
}),
SelectFilter::make('verification_status')
->label('Verification')
->options([
'unknown' => 'Unknown',
'pending' => 'Pending',
'healthy' => 'Healthy',
'degraded' => 'Degraded',
'blocked' => 'Blocked',
'error' => 'Error',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '') {
return $query;
}
return $query->where('provider_connections.verification_status', $value);
}),
SelectFilter::make('status') SelectFilter::make('status')
->label('Status') ->label('Diagnostic status')
->options([ ->options([
'connected' => 'Connected', 'connected' => 'Connected',
'needs_consent' => 'Needs consent', 'needs_consent' => 'Needs consent',
@ -732,7 +814,7 @@ public static function table(Table $table): Table
return $query->where('provider_connections.status', $value); return $query->where('provider_connections.status', $value);
}), }),
SelectFilter::make('health_status') SelectFilter::make('health_status')
->label('Health') ->label('Diagnostic health')
->options([ ->options([
'ok' => 'OK', 'ok' => 'OK',
'degraded' => 'Degraded', 'degraded' => 'Degraded',

View File

@ -284,13 +284,6 @@ public static function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription) ->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('app_status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at') Tables\Columns\TextColumn::make('created_at')
->dateTime() ->dateTime()
->since() ->since()
@ -310,13 +303,6 @@ public static function table(Table $table): Table
'staging' => 'STAGING', 'staging' => 'STAGING',
'other' => 'Other', 'other' => 'Other',
]), ]),
Tables\Filters\SelectFilter::make('app_status')
->options([
'ok' => 'OK',
'consent_required' => 'Consent required',
'error' => 'Error',
'unknown' => 'Unknown',
]),
]) ])
->actions([ ->actions([
Actions\Action::make('related_onboarding') Actions\Action::make('related_onboarding')
@ -842,12 +828,6 @@ public static function infolist(Schema $schema): Schema
->label('Lifecycle summary') ->label('Lifecycle summary')
->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription) ->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription)
->columnSpanFull(), ->columnSpanFull(),
Infolists\Components\TextEntry::make('app_status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
@ -1492,19 +1472,30 @@ private static function providerConnectionState(Tenant $tenant): array
{ {
$ctaUrl = ProviderConnectionResource::getUrl('index', ['tenant_id' => (string) $tenant->external_id], panel: 'admin'); $ctaUrl = ProviderConnectionResource::getUrl('index', ['tenant_id' => (string) $tenant->external_id], panel: 'admin');
$connection = ProviderConnection::query() $defaultConnection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft') ->where('provider', 'microsoft')
->orderByDesc('is_default') ->where('is_default', true)
->orderBy('id') ->orderBy('id')
->first(); ->first();
$connection = $defaultConnection instanceof ProviderConnection
? $defaultConnection
: ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->orderBy('id')
->first();
if (! $connection instanceof ProviderConnection) { if (! $connection instanceof ProviderConnection) {
return [ return [
'state' => 'needs_action', 'state' => 'missing',
'cta_url' => $ctaUrl, 'cta_url' => $ctaUrl,
'needs_default_connection' => false,
'display_name' => null, 'display_name' => null,
'provider' => null, 'provider' => null,
'consent_status' => null,
'verification_status' => null,
'status' => null, 'status' => null,
'health_status' => null, 'health_status' => null,
'last_health_check_at' => null, 'last_health_check_at' => null,
@ -1515,8 +1506,15 @@ private static function providerConnectionState(Tenant $tenant): array
return [ return [
'state' => $connection->is_default ? 'default_configured' : 'configured', 'state' => $connection->is_default ? 'default_configured' : 'configured',
'cta_url' => $ctaUrl, 'cta_url' => $ctaUrl,
'needs_default_connection' => ! $connection->is_default,
'display_name' => (string) $connection->display_name, 'display_name' => (string) $connection->display_name,
'provider' => (string) $connection->provider, 'provider' => (string) $connection->provider,
'consent_status' => $connection->consent_status instanceof BackedEnum
? (string) $connection->consent_status->value
: (is_string($connection->consent_status) ? $connection->consent_status : null),
'verification_status' => $connection->verification_status instanceof BackedEnum
? (string) $connection->verification_status->value
: (is_string($connection->verification_status) ? $connection->verification_status : null),
'status' => is_string($connection->status) ? $connection->status : null, 'status' => is_string($connection->status) ? $connection->status : null,
'health_status' => is_string($connection->health_status) ? $connection->health_status : null, 'health_status' => is_string($connection->health_status) ? $connection->health_status : null,
'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(), 'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(),

View File

@ -12,6 +12,7 @@
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -113,16 +114,46 @@ public function table(Table $table): Table
->state(fn (OperationRun $record): string => '#'.$record->getKey()), ->state(fn (OperationRun $record): string => '#'.$record->getKey()),
TextColumn::make('status') TextColumn::make('status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) 'status' => (string) $record->status,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) 'freshness_state' => $record->freshnessState()->value,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), ])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
TextColumn::make('outcome') TextColumn::make('outcome')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) 'outcome' => (string) $record->outcome,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) 'status' => (string) $record->status,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), 'freshness_state' => $record->freshnessState()->value,
])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
TextColumn::make('type') TextColumn::make('type')
->label('Operation') ->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))

View File

@ -10,6 +10,7 @@
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -94,16 +95,46 @@ public function table(Table $table): Table
->state(fn (OperationRun $record): string => '#'.$record->getKey()), ->state(fn (OperationRun $record): string => '#'.$record->getKey()),
TextColumn::make('status') TextColumn::make('status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) 'status' => (string) $record->status,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) 'freshness_state' => $record->freshnessState()->value,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), ])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
TextColumn::make('outcome') TextColumn::make('outcome')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) 'outcome' => (string) $record->outcome,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) 'status' => (string) $record->status,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), 'freshness_state' => $record->freshnessState()->value,
])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
TextColumn::make('type') TextColumn::make('type')
->label('Operation') ->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))

View File

@ -11,6 +11,7 @@
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\StuckRunClassifier; use App\Support\SystemConsole\StuckRunClassifier;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -112,10 +113,23 @@ public function table(Table $table): Table
->state(fn (OperationRun $record): string => '#'.$record->getKey()), ->state(fn (OperationRun $record): string => '#'.$record->getKey()),
TextColumn::make('status') TextColumn::make('status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) 'status' => (string) $record->status,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) 'freshness_state' => $record->freshnessState()->value,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), ])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(null),
TextColumn::make('stuck_class') TextColumn::make('stuck_class')
->label('Stuck class') ->label('Stuck class')
->state(function (OperationRun $record): string { ->state(function (OperationRun $record): string {
@ -126,6 +140,7 @@ public function table(Table $table): Table
TextColumn::make('type') TextColumn::make('type')
->label('Operation') ->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record))
->searchable(), ->searchable(),
TextColumn::make('workspace.name') TextColumn::make('workspace.name')
->label('Workspace') ->label('Workspace')

View File

@ -23,13 +23,7 @@ class DashboardKpis extends StatsOverviewWidget
protected function getPollingInterval(): ?string protected function getPollingInterval(): ?string
{ {
$tenant = Filament::getTenant(); return ActiveRuns::pollingIntervalForTenant(Filament::getTenant());
if (! $tenant instanceof Tenant) {
return null;
}
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
} }
/** /**
@ -60,9 +54,14 @@ protected function getStats(): array
->healthyActive() ->healthyActive()
->count(); ->count();
$followUpRuns = (int) OperationRun::query() $staleActiveRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->dashboardNeedsFollowUp() ->activeStaleAttention()
->count();
$terminalFollowUpRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->terminalFollowUp()
->count(); ->count();
$openDriftUrl = $openDriftFindings > 0 $openDriftUrl = $openDriftFindings > 0
@ -96,10 +95,26 @@ protected function getStats(): array
->description('healthy queued or running tenant work') ->description('healthy queued or running tenant work')
->color($activeRuns > 0 ? 'info' : 'gray') ->color($activeRuns > 0 ? 'info' : 'gray')
->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null), ->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null),
Stat::make('Operations needing follow-up', $followUpRuns) Stat::make('Likely stale operations', $staleActiveRuns)
->description('failed, warning, or stalled runs') ->description('queued or running past the lifecycle window')
->color($followUpRuns > 0 ? 'danger' : 'gray') ->color($staleActiveRuns > 0 ? 'warning' : 'gray')
->url($followUpRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'blocked') : null), ->url($staleActiveRuns > 0
? OperationRunLinks::index(
$tenant,
activeTab: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
problemClass: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
)
: null),
Stat::make('Terminal follow-up operations', $terminalFollowUpRuns)
->description('blocked, partial, failed, or auto-reconciled runs')
->color($terminalFollowUpRuns > 0 ? 'danger' : 'gray')
->url($terminalFollowUpRuns > 0
? OperationRunLinks::index(
$tenant,
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
)
: null),
]; ];
} }
@ -112,7 +127,8 @@ private function emptyStats(): array
Stat::make('Open drift findings', 0), Stat::make('Open drift findings', 0),
Stat::make('High severity active findings', 0), Stat::make('High severity active findings', 0),
Stat::make('Active operations', 0), Stat::make('Active operations', 0),
Stat::make('Operations needing follow-up', 0), Stat::make('Likely stale operations', 0),
Stat::make('Terminal follow-up operations', 0),
]; ];
} }

View File

@ -48,9 +48,13 @@ protected function getViewData(): array
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount; $lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
$expiringGovernanceCount = $aggregate->expiringGovernanceCount; $expiringGovernanceCount = $aggregate->expiringGovernanceCount;
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount; $highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
$operationsFollowUpCount = (int) OperationRun::query() $staleActiveOperationsCount = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->dashboardNeedsFollowUp() ->activeStaleAttention()
->count();
$terminalFollowUpOperationsCount = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->terminalFollowUp()
->count(); ->count();
$activeRuns = (int) OperationRun::query() $activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
@ -139,15 +143,35 @@ protected function getViewData(): array
]; ];
} }
if ($operationsFollowUpCount > 0) { if ($staleActiveOperationsCount > 0) {
$items[] = [ $items[] = [
'key' => 'operations_follow_up', 'key' => 'operations_stale_attention',
'title' => 'Operations need follow-up', 'title' => 'Active operations look stale',
'body' => "{$operationsFollowUpCount} run(s) failed, completed with warnings, or look stalled.", 'body' => "{$staleActiveOperationsCount} run(s) are still marked active but are past the lifecycle window.",
'badge' => 'Operations',
'badgeColor' => 'warning',
'actionLabel' => 'Open stale operations',
'actionUrl' => OperationRunLinks::index(
$tenant,
activeTab: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
problemClass: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
),
];
}
if ($terminalFollowUpOperationsCount > 0) {
$items[] = [
'key' => 'operations_terminal_follow_up',
'title' => 'Terminal operations need follow-up',
'body' => "{$terminalFollowUpOperationsCount} run(s) finished blocked, partially, failed, or were automatically reconciled.",
'badge' => 'Operations', 'badge' => 'Operations',
'badgeColor' => 'danger', 'badgeColor' => 'danger',
'actionLabel' => 'Open operations', 'actionLabel' => 'Open terminal follow-up',
'actionUrl' => OperationRunLinks::index($tenant, activeTab: 'blocked'), 'actionUrl' => OperationRunLinks::index(
$tenant,
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
),
]; ];
} }
@ -184,7 +208,7 @@ protected function getViewData(): array
} }
return [ return [
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, 'pollingInterval' => ActiveRuns::pollingIntervalForTenant($tenant),
'items' => $items, 'items' => $items,
'healthyChecks' => $healthyChecks, 'healthyChecks' => $healthyChecks,
]; ];

View File

@ -29,7 +29,7 @@ public function table(Table $table): Table
return $table return $table
->heading('Recent Operations') ->heading('Recent Operations')
->query($this->getQuery()) ->query($this->getQuery())
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null) ->poll(fn (): ?string => ActiveRuns::pollingIntervalForTenant($tenant instanceof Tenant ? $tenant : null))
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::widget()) ->paginated(\App\Support\Filament\TablePaginationProfiles::widget())
->columns([ ->columns([
@ -43,22 +43,52 @@ public function table(Table $table): Table
->sortable() ->sortable()
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->limit(40) ->limit(40)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record))
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)), ->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
TextColumn::make('status') TextColumn::make('status')
->badge() ->badge()
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) 'status' => (string) $record->status,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) 'freshness_state' => $record->freshnessState()->value,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), ])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(null),
TextColumn::make('outcome') TextColumn::make('outcome')
->badge() ->badge()
->sortable() ->sortable()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) 'outcome' => (string) $record->outcome,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) 'status' => (string) $record->status,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)) 'freshness_state' => $record->freshnessState()->value,
])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)), ->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
TextColumn::make('created_at') TextColumn::make('created_at')
->label('Started') ->label('Started')

View File

@ -5,16 +5,16 @@
namespace App\Filament\Widgets\Inventory; namespace App\Filament\Widgets\Inventory;
use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; 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\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Inventory\InventoryKpiBadges; use App\Support\Inventory\InventoryKpiBadges;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\TenantCoverageTruth;
use App\Support\OperationRunOutcome; use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\OperationRunStatus; use App\Support\OperationRunLinks;
use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
@ -28,12 +28,9 @@ class InventoryKpiHeader extends StatsOverviewWidget
protected int|string|array $columnSpan = 'full'; 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> * @return array<Stat>
*/ */
protected function getStats(): array protected function getStats(): array
@ -43,126 +40,85 @@ protected function getStats(): array
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return [ return [
Stat::make('Total items', 0), Stat::make('Total items', 0),
Stat::make('Coverage', '0%')->description('Select a tenant to load coverage.'), Stat::make('Covered types', '—')->description('Select a tenant to load coverage truth.'),
Stat::make('Last inventory sync', '—')->description('Select a tenant to see the latest sync.'), 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('Active ops', 0),
Stat::make('Inventory ops', 0)->description('Select a tenant to load dependency and risk counts.'),
]; ];
} }
$tenantId = (int) $tenant->getKey(); $truth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
/** @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,
]);
$activeOps = (int) OperationRun::query() $activeOps = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', (int) $tenant->getKey())
->active() ->active()
->count(); ->count();
$inventoryOps = (int) OperationRun::query() $inventoryOps = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync') ->where('type', 'inventory_sync')
->active() ->active()
->count(); ->count();
$resolver = app(CoverageCapabilitiesResolver::class);
$dependenciesItems = 0;
foreach ($countsByPolicyType as $policyType => $count) {
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
$dependenciesItems += $count;
}
}
return [ return [
Stat::make('Total items', $totalItems), Stat::make('Total items', $truth->observedItemTotal)
Stat::make('Coverage', $coveragePercent.'%') ->description(sprintf('Observed across %d supported types.', $truth->observedTypeCount())),
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))), Stat::make('Covered types', sprintf('%d / %d', $truth->succeededTypeCount, $truth->supportedTypeCount))
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel) ->description(new HtmlString(InventoryKpiBadges::coverageBreakdown(
->description(new HtmlString($lastInventorySyncDescription)), $truth->failedTypeCount,
Stat::make('Active ops', $activeOps), $truth->skippedTypeCount,
Stat::make('Inventory ops', $inventoryOps) $truth->unknownTypeCount,
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))), ))),
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 && $user->can('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

@ -58,6 +58,8 @@ protected function getViewData(): array
'type', 'type',
'status', 'status',
'outcome', 'outcome',
'context',
'failure_summary',
'created_at', 'created_at',
'started_at', 'started_at',
'completed_at', 'completed_at',

View File

@ -201,7 +201,7 @@ protected function getViewData(): array
&& $user->can(Capabilities::PROVIDER_RUN, $tenant); && $user->can(Capabilities::PROVIDER_RUN, $tenant);
$lifecycleNotice = $isTenantMember && ! $canOperate $lifecycleNotice = $isTenantMember && ! $canOperate
? 'Verification can be started from tenant management only while the tenant is active.' ? 'Verification can be started from tenant management only while the tenant is active. Consent and connection configuration remain separate from this stored verification report.'
: null; : null;
$runData = null; $runData = null;

View File

@ -23,6 +23,7 @@ class WorkspaceRecentOperations extends Widget
* status_color: string, * status_color: string,
* outcome_label: string, * outcome_label: string,
* outcome_color: string, * outcome_color: string,
* lifecycle_label: ?string,
* guidance: ?string, * guidance: ?string,
* started_at: string, * started_at: string,
* destination: array<string, mixed>, * destination: array<string, mixed>,
@ -50,6 +51,7 @@ class WorkspaceRecentOperations extends Widget
* status_color: string, * status_color: string,
* outcome_label: string, * outcome_label: string,
* outcome_color: string, * outcome_color: string,
* lifecycle_label: ?string,
* guidance: ?string, * guidance: ?string,
* started_at: string, * started_at: string,
* destination: array<string, mixed>, * destination: array<string, mixed>,

View File

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

@ -4,6 +4,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OpsUx\ActiveRuns;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -85,13 +86,13 @@ public function refreshRuns(): void
$query = OperationRun::query() $query = OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->active() ->healthyActive()
->orderByDesc('created_at'); ->orderByDesc('created_at');
$activeCount = (clone $query)->count(); $activeCount = (clone $query)->count();
$this->runs = (clone $query)->limit(6)->get(); $this->runs = (clone $query)->limit(6)->get();
$this->overflowCount = max(0, $activeCount - 5); $this->overflowCount = max(0, $activeCount - 5);
$this->hasActiveRuns = $this->runs->isNotEmpty(); $this->hasActiveRuns = ActiveRuns::existForTenantId($tenantId);
} }
public function render(): \Illuminate\Contracts\View\View public function render(): \Illuminate\Contracts\View\View

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -17,6 +18,12 @@ class OperationRun extends Model
{ {
use HasFactory; use HasFactory;
public const string PROBLEM_CLASS_NONE = 'none';
public const string PROBLEM_CLASS_ACTIVE_STALE_ATTENTION = 'active_stale_attention';
public const string PROBLEM_CLASS_TERMINAL_FOLLOW_UP = 'terminal_follow_up';
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
@ -178,20 +185,34 @@ public function scopeDashboardNeedsFollowUp(Builder $query): Builder
return $query->where(function (Builder $query): void { return $query->where(function (Builder $query): void {
$query $query
->where(function (Builder $terminalQuery): void { ->where(function (Builder $terminalQuery): void {
$terminalQuery $terminalQuery->terminalFollowUp();
->where('status', OperationRunStatus::Completed->value)
->whereIn('outcome', [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
]);
}) })
->orWhere(function (Builder $activeQuery): void { ->orWhere(function (Builder $activeQuery): void {
$activeQuery->likelyStale(); $activeQuery->activeStaleAttention();
}); });
}); });
} }
public function scopeActiveStaleAttention(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
{
return $query->likelyStale($policy);
}
public function scopeTerminalFollowUp(Builder $query): Builder
{
return $query
->where('status', OperationRunStatus::Completed->value)
->where(function (Builder $query): void {
$query
->whereIn('outcome', [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
])
->orWhereNotNull('context->reconciliation->reconciled_at');
});
}
public function getSelectionHashAttribute(): ?string public function getSelectionHashAttribute(): ?string
{ {
$context = is_array($this->context) ? $this->context : []; $context = is_array($this->context) ? $this->context : [];
@ -253,11 +274,33 @@ public function setFinishedAtAttribute(mixed $value): void
$this->completed_at = $value; $this->completed_at = $value;
} }
public function inventoryCoverage(): ?InventoryCoverage
{
return InventoryCoverage::fromContext($this->context);
}
public function isGovernanceArtifactOperation(): bool public function isGovernanceArtifactOperation(): bool
{ {
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type); 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 public function supportsOperatorExplanation(): bool
{ {
return OperationCatalog::supportsOperatorExplanation((string) $this->type); return OperationCatalog::supportsOperatorExplanation((string) $this->type);
@ -317,17 +360,64 @@ public function freshnessState(): OperationRunFreshnessState
return OperationRunFreshnessState::forRun($this); return OperationRunFreshnessState::forRun($this);
} }
public function requiresDashboardFollowUp(): bool /**
* @return list<string>
*/
public static function problemClasses(): array
{ {
return [
self::PROBLEM_CLASS_NONE,
self::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
];
}
public function problemClass(): string
{
$freshnessState = $this->freshnessState();
if ($freshnessState->isLikelyStale()) {
return self::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
}
if ($freshnessState->isReconciledFailed()) {
return self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP;
}
if ((string) $this->status === OperationRunStatus::Completed->value) { if ((string) $this->status === OperationRunStatus::Completed->value) {
return in_array((string) $this->outcome, [ return in_array((string) $this->outcome, [
OperationRunOutcome::Blocked->value, OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value, OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value, OperationRunOutcome::Failed->value,
], true); ], true)
? self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
: self::PROBLEM_CLASS_NONE;
} }
return $this->freshnessState()->isLikelyStale(); return self::PROBLEM_CLASS_NONE;
}
public function hasStaleLineage(): bool
{
return $this->freshnessState()->isReconciledFailed();
}
public function isCurrentlyActive(): bool
{
return in_array((string) $this->status, [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
], true);
}
public function requiresOperatorReview(): bool
{
return $this->problemClass() !== self::PROBLEM_CLASS_NONE;
}
public function requiresDashboardFollowUp(): bool
{
return $this->requiresOperatorReview();
} }
/** /**

View File

@ -82,17 +82,24 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
continue; continue;
} }
$statusByType[$type] = InventoryCoverage::StatusSkipped; $statusByType[$type] = [
'status' => InventoryCoverage::StatusSkipped,
'item_count' => 0,
];
} }
$result = $this->executeSelection( $result = $this->executeSelection(
$operationRun, $operationRun,
$tenant, $tenant,
$normalizedSelection, $normalizedSelection,
function (string $policyType, bool $success, ?string $errorCode) use (&$statusByType): void { function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use (&$statusByType): void {
$statusByType[$policyType] = $success $statusByType[$policyType] = array_filter([
? InventoryCoverage::StatusSucceeded 'status' => $success
: InventoryCoverage::StatusFailed; ? 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 : []; $updatedContext = is_array($operationRun->context) ? $operationRun->context : [];
$coverageStatusByType = $statusByType; $coverageStatusByType = $statusByType;
$skippedErrorCode = is_string($errorCodes[0] ?? null) ? (string) $errorCodes[0] : null;
if ($status === 'skipped') { if ($status === 'skipped') {
foreach ($coverageStatusByType as $type => $coverageStatus) { 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. * This method MUST NOT create or update legacy sync-run rows; OperationRun is canonical.
* *
* @param array<string, mixed> $selectionPayload * @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} * @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 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 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} * @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 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 = []; $errorCodes = [];
$hadErrors = false; $hadErrors = false;
$warnings = []; $warnings = [];
$observedByType = [];
try { try {
$connection = $this->resolveProviderConnection($tenant); $connection = $this->resolveProviderConnection($tenant);
@ -277,7 +290,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
$hadErrors = true; $hadErrors = true;
$errors++; $errors++;
$errorCodes[] = 'unsupported_type'; $errorCodes[] = 'unsupported_type';
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type'); $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type', 0);
continue; continue;
} }
@ -293,7 +306,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
$errors++; $errors++;
$errorCode = $this->mapGraphFailureToErrorCode($response); $errorCode = $this->mapGraphFailureToErrorCode($response);
$errorCodes[] = $errorCode; $errorCodes[] = $errorCode;
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode); $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode, 0);
continue; continue;
} }
@ -313,6 +326,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
} }
$observed++; $observed++;
$observedByType[$policyType] = (int) ($observedByType[$policyType] ?? 0) + 1;
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true); $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 [ return [

View File

@ -27,6 +27,7 @@ final class BadgeCatalog
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class, BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class, BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class, BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
BadgeDomain::InventoryCoverageState->value => Domains\InventoryCoverageStateBadge::class,
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class, BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class, BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class, BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
@ -46,6 +47,8 @@ final class BadgeCatalog
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class, BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class, BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class, BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
BadgeDomain::ProviderConsentStatus->value => Domains\ProviderConsentStatusBadge::class,
BadgeDomain::ProviderVerificationStatus->value => Domains\ProviderVerificationStatusBadge::class,
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class, BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class, BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class, BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
@ -166,6 +169,32 @@ public static function normalizeProviderConnectionStatus(mixed $value): ?string
}; };
} }
public static function normalizeProviderConsentStatus(mixed $value): ?string
{
$state = self::normalizeState($value);
return match ($state) {
'needs_admin_consent', 'needs_consent', 'consent_required' => 'required',
'connected' => 'granted',
'error' => 'failed',
default => $state,
};
}
public static function normalizeProviderVerificationStatus(mixed $value): ?string
{
$state = self::normalizeState($value);
return match ($state) {
'not_started', 'never_checked' => 'unknown',
'in_progress' => 'pending',
'ok' => 'healthy',
'warning', 'needs_attention' => 'degraded',
'failed' => 'error',
default => $state,
};
}
public static function normalizeProviderConnectionHealth(mixed $value): ?string public static function normalizeProviderConnectionHealth(mixed $value): ?string
{ {
$state = self::normalizeState($value); $state = self::normalizeState($value);

View File

@ -18,6 +18,7 @@ enum BadgeDomain: string
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status'; case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
case OperationRunStatus = 'operation_run_status'; case OperationRunStatus = 'operation_run_status';
case OperationRunOutcome = 'operation_run_outcome'; case OperationRunOutcome = 'operation_run_outcome';
case InventoryCoverageState = 'inventory_coverage_state';
case BackupSetStatus = 'backup_set_status'; case BackupSetStatus = 'backup_set_status';
case RestoreRunStatus = 'restore_run_status'; case RestoreRunStatus = 'restore_run_status';
case RestoreCheckSeverity = 'restore_check_severity'; case RestoreCheckSeverity = 'restore_check_severity';
@ -37,6 +38,8 @@ enum BadgeDomain: string
case IgnoredAt = 'ignored_at'; case IgnoredAt = 'ignored_at';
case RestorePreviewDecision = 'restore_preview_decision'; case RestorePreviewDecision = 'restore_preview_decision';
case RestoreResultStatus = 'restore_result_status'; case RestoreResultStatus = 'restore_result_status';
case ProviderConsentStatus = 'provider_connection.consent_status';
case ProviderVerificationStatus = 'provider_connection.verification_status';
case ProviderConnectionStatus = 'provider_connection.status'; case ProviderConnectionStatus = 'provider_connection.status';
case ProviderConnectionHealth = 'provider_connection.health'; case ProviderConnectionHealth = 'provider_connection.health';
case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status'; case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status';

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

@ -0,0 +1,24 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class ProviderConsentStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeProviderConsentStatus($value);
return match ($state) {
'required' => new BadgeSpec('Required', 'warning', 'heroicon-m-exclamation-triangle'),
'granted' => new BadgeSpec('Granted', 'success', 'heroicon-m-check-circle'),
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
'revoked' => new BadgeSpec('Revoked', 'danger', 'heroicon-m-no-symbol'),
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class ProviderVerificationStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeProviderVerificationStatus($value);
return match ($state) {
'pending' => new BadgeSpec('Pending', 'info', 'heroicon-m-clock'),
'healthy' => new BadgeSpec('Healthy', 'success', 'heroicon-m-check-circle'),
'degraded' => new BadgeSpec('Degraded', 'warning', 'heroicon-m-exclamation-triangle'),
'blocked' => new BadgeSpec('Blocked', 'danger', 'heroicon-m-no-symbol'),
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-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_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. * 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 * @param list<string> $foundationTypes
* @return array{policy_types: array<string, array{status: string}>, foundation_types: array<string, array{status: string}>} * @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; continue;
} }
$normalizedStatus = self::normalizeStatus($status); $row = self::normalizeBuildRow($status);
if ($normalizedStatus === null) { if ($row === null) {
continue; continue;
} }
$row = ['status' => $normalizedStatus];
if (array_key_exists($type, $foundationLookup)) { if (array_key_exists($type, $foundationLookup)) {
$foundations[$type] = $row; $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 private static function normalizeStatus(mixed $status): ?string
{ {
if (! is_string($status)) { if (! is_string($status)) {

View File

@ -8,39 +8,75 @@
class InventoryKpiBadges 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' if ($failedCount === 0 && $skippedCount === 0 && $unknownCount === 0) {
<div class="flex items-center gap-2"> return Blade::render(<<<'BLADE'
<x-filament::badge color="success" size="sm"> <div class="flex items-center gap-2">
Restorable {{ $restorableCount }} <x-filament::badge color="success" size="sm">
</x-filament::badge> No follow-up
</x-filament::badge>
</div>
BLADE);
}
<x-filament::badge color="warning" size="sm"> return Blade::render(<<<'BLADE'
Partial {{ $partialCount }} <div class="flex flex-wrap items-center gap-2">
</x-filament::badge> @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> </div>
BLADE, [ BLADE, [
'restorableCount' => $restorableCount, 'failedCount' => $failedCount,
'partialCount' => $partialCount, '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' 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"> <x-filament::badge color="gray" size="sm">
Dependencies {{ $dependenciesCount }} {{ $topPriorityLabel }}
</x-filament::badge> </x-filament::badge>
<x-filament::badge color="danger" size="sm"> <x-filament::badge color="info" size="sm">
Risk {{ $riskCount }} Observed {{ $observedItemTotal }}
</x-filament::badge> </x-filament::badge>
<span class="text-xs text-gray-600 dark:text-gray-300">
{{ $observedTypeCount }} supported types currently observed
</span>
</div> </div>
BLADE, [ BLADE, [
'dependenciesCount' => $dependenciesCount, 'topPriorityLabel' => $topPriorityRow->label,
'riskCount' => $riskCount, '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

@ -188,13 +188,15 @@ private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
return null; return null;
} }
$tenantKeyColumn = (new Tenant)->getQualifiedKeyName();
return Tenant::query() return Tenant::query()
->withTrashed() ->withTrashed()
->where(static function ($query) use ($routeTenant): void { ->where(static function ($query) use ($routeTenant, $tenantKeyColumn): void {
$query->where('external_id', $routeTenant); $query->where('external_id', $routeTenant);
if (ctype_digit($routeTenant)) { if (ctype_digit($routeTenant)) {
$query->orWhereKey((int) $routeTenant); $query->orWhere($tenantKeyColumn, (int) $routeTenant);
} }
}) })
->first(); ->first();

View File

@ -80,6 +80,7 @@ public static function index(
?CanonicalNavigationContext $context = null, ?CanonicalNavigationContext $context = null,
?string $activeTab = null, ?string $activeTab = null,
bool $allTenants = false, bool $allTenants = false,
?string $problemClass = null,
): string { ): string {
$parameters = $context?->toQuery() ?? []; $parameters = $context?->toQuery() ?? [];
@ -93,6 +94,18 @@ public static function index(
$parameters['activeTab'] = $activeTab; $parameters['activeTab'] = $activeTab;
} }
if (
is_string($problemClass)
&& in_array($problemClass, OperationRun::problemClasses(), true)
&& $problemClass !== OperationRun::PROBLEM_CLASS_NONE
) {
$parameters['problemClass'] = $problemClass;
if (! is_string($activeTab) || $activeTab === '') {
$parameters['activeTab'] = $problemClass;
}
}
return route('admin.operations.index', $parameters); return route('admin.operations.index', $parameters);
} }

View File

@ -11,9 +11,30 @@ final class ActiveRuns
{ {
public static function existForTenant(Tenant $tenant): bool public static function existForTenant(Tenant $tenant): bool
{ {
return self::existForTenantId((int) $tenant->getKey());
}
public static function existForTenantId(?int $tenantId): bool
{
if (! is_int($tenantId) || $tenantId <= 0) {
return false;
}
return OperationRun::query() return OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenantId)
->active() ->healthyActive()
->exists(); ->exists();
} }
public static function pollingIntervalForTenant(?Tenant $tenant): ?string
{
return $tenant instanceof Tenant
? self::pollingIntervalForTenantId((int) $tenant->getKey())
: null;
}
public static function pollingIntervalForTenantId(?int $tenantId): ?string
{
return self::existForTenantId($tenantId) ? '10s' : null;
}
} }

View File

@ -223,6 +223,70 @@ public static function freshnessState(OperationRun $run): OperationRunFreshnessS
return $run->freshnessState(); return $run->freshnessState();
} }
public static function problemClass(OperationRun $run): string
{
return $run->problemClass();
}
public static function problemClassLabel(OperationRun $run): ?string
{
return match (self::problemClass($run)) {
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 'Likely stale active run',
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => 'Terminal follow-up',
default => null,
};
}
public static function staleLineageNote(OperationRun $run): ?string
{
if (! $run->hasStaleLineage()) {
return null;
}
return 'This terminal run was automatically reconciled after stale lifecycle truth was lost.';
}
/**
* @return array{
* freshnessState:string,
* freshnessLabel:?string,
* problemClass:string,
* problemClassLabel:?string,
* isCurrentlyActive:bool,
* isReconciled:bool,
* staleLineageNote:?string,
* primaryNextAction:string,
* attentionNote:?string
* }
*/
public static function decisionZoneTruth(OperationRun $run): array
{
$freshnessState = self::freshnessState($run);
return [
'freshnessState' => $freshnessState->value,
'freshnessLabel' => self::lifecycleAttentionSummary($run),
'problemClass' => self::problemClass($run),
'problemClassLabel' => self::problemClassLabel($run),
'isCurrentlyActive' => $run->isCurrentlyActive(),
'isReconciled' => $run->isLifecycleReconciled(),
'staleLineageNote' => self::staleLineageNote($run),
'primaryNextAction' => self::surfaceGuidance($run) ?? 'No action needed.',
'attentionNote' => self::decisionAttentionNote($run),
];
}
public static function decisionAttentionNote(OperationRun $run): ?string
{
return match (self::problemClass($run)) {
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 'Still active: Yes. Automatic reconciliation: No. This run is past its lifecycle window and needs stale-run investigation before retrying.',
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $run->hasStaleLineage()
? 'Still active: No. Automatic reconciliation: Yes. This terminal failure preserves stale-run lineage so operators can recover why the run stopped.'
: 'Still active: No. Automatic reconciliation: No. This run is terminal and still needs follow-up.',
default => null,
};
}
public static function lifecycleAttentionSummary(OperationRun $run): ?string public static function lifecycleAttentionSummary(OperationRun $run): ?string
{ {
return self::memoizeExplanation( return self::memoizeExplanation(
@ -247,7 +311,9 @@ private static function buildLifecycleAttentionSummary(OperationRun $run): ?stri
return match (self::freshnessState($run)) { return match (self::freshnessState($run)) {
OperationRunFreshnessState::LikelyStale => 'Likely stale', OperationRunFreshnessState::LikelyStale => 'Likely stale',
OperationRunFreshnessState::ReconciledFailed => 'Automatically reconciled', OperationRunFreshnessState::ReconciledFailed => 'Automatically reconciled',
default => null, default => self::problemClass($run) === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
? 'Terminal follow-up'
: null,
}; };
} }

View File

@ -33,6 +33,7 @@
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore; use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use App\Support\Ui\OperatorExplanation\CountDescriptor; use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder; use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@ -359,7 +360,7 @@ private function buildEvidenceSnapshotEnvelope(EvidenceSnapshot $snapshot): Arti
nextActionUrl: $nextActionUrl, nextActionUrl: $nextActionUrl,
relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : null, relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : null,
relatedArtifactUrl: $snapshot->tenant !== null relatedArtifactUrl: $snapshot->tenant !== null
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant) ? $this->panelSafeTenantArtifactUrl(fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant))
: null, : null,
includePublicationDimension: false, includePublicationDimension: false,
countDescriptors: [ countDescriptors: [
@ -500,9 +501,13 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
: null; : null;
if ($publishBlockers !== [] && $review->tenant !== null) { if ($publishBlockers !== [] && $review->tenant !== null) {
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant); $nextActionUrl = $this->panelSafeTenantArtifactUrl(
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
);
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $review->tenant !== null && $review->evidenceSnapshot !== null) { } elseif (($freshnessState === 'stale' || $contentState === 'partial') && $review->tenant !== null && $review->evidenceSnapshot !== null) {
$nextActionUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $review->tenant); $nextActionUrl = $this->panelSafeTenantArtifactUrl(
fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $review->tenant)
);
} }
return $this->makeEnvelope( return $this->makeEnvelope(
@ -538,7 +543,9 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
nextActionUrl: $nextActionUrl, nextActionUrl: $nextActionUrl,
relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null, relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null,
relatedArtifactUrl: $review->tenant !== null relatedArtifactUrl: $review->tenant !== null
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant) ? $this->panelSafeTenantArtifactUrl(
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
)
: null, : null,
includePublicationDimension: true, includePublicationDimension: true,
countDescriptors: [ countDescriptors: [
@ -675,9 +682,13 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
$nextActionUrl = null; $nextActionUrl = null;
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) { if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant); $nextActionUrl = $this->panelSafeTenantArtifactUrl(
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant)
);
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $pack->tenant !== null && $pack->evidenceSnapshot !== null) { } elseif (($freshnessState === 'stale' || $contentState === 'partial') && $pack->tenant !== null && $pack->evidenceSnapshot !== null) {
$nextActionUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $pack->evidenceSnapshot], tenant: $pack->tenant); $nextActionUrl = $this->panelSafeTenantArtifactUrl(
fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $pack->evidenceSnapshot], tenant: $pack->tenant)
);
} elseif ($pack->operation_run_id !== null) { } elseif ($pack->operation_run_id !== null) {
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id); $nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
} }
@ -715,7 +726,9 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
nextActionUrl: $nextActionUrl, nextActionUrl: $nextActionUrl,
relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : null, relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : null,
relatedArtifactUrl: $pack->tenant !== null relatedArtifactUrl: $pack->tenant !== null
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant) ? $this->panelSafeTenantArtifactUrl(
fn (): string => ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
)
: null, : null,
includePublicationDimension: true, includePublicationDimension: true,
countDescriptors: [ countDescriptors: [
@ -1084,6 +1097,13 @@ classification: $classification,
); );
} }
private function panelSafeTenantArtifactUrl(callable $resolver): ?string
{
return Filament::getCurrentPanel()?->getId() === 'system'
? null
: $resolver();
}
/** /**
* @return array<int, CountDescriptor> * @return array<int, CountDescriptor>
*/ */

View File

@ -63,6 +63,12 @@ public function build(Workspace $workspace, User $user): array
static fn (array $context): bool => (bool) ($context['has_governance_attention'] ?? false), static fn (array $context): bool => (bool) ($context['has_governance_attention'] ?? false),
)); ));
$totalProblemOperationsCount = array_sum(array_map(
static fn (array $context): int => (int) ($context['terminal_follow_up_operations_count'] ?? 0)
+ (int) ($context['stale_attention_operations_count'] ?? 0),
$tenantContexts,
));
$totalActiveOperationsCount = (int) $this->scopeToAuthorizedTenants( $totalActiveOperationsCount = (int) $this->scopeToAuthorizedTenants(
OperationRun::query(), OperationRun::query(),
$workspaceId, $workspaceId,
@ -86,6 +92,7 @@ public function build(Workspace $workspace, User $user): array
accessibleTenantCount: $accessibleTenants->count(), accessibleTenantCount: $accessibleTenants->count(),
attentionItems: $attentionItems, attentionItems: $attentionItems,
governanceAttentionTenantCount: $governanceAttentionTenantCount, governanceAttentionTenantCount: $governanceAttentionTenantCount,
totalProblemOperationsCount: $totalProblemOperationsCount,
totalActiveOperationsCount: $totalActiveOperationsCount, totalActiveOperationsCount: $totalActiveOperationsCount,
totalAlertFailuresCount: $totalAlertFailuresCount, totalAlertFailuresCount: $totalAlertFailuresCount,
canViewAlerts: $canViewAlerts, canViewAlerts: $canViewAlerts,
@ -176,18 +183,28 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
return []; return [];
} }
$followUpRuns = $this->scopeToVisibleTenants( $terminalFollowUpCounts = $this->scopeToVisibleTenants(
OperationRun::query()->with('tenant'), OperationRun::query(),
$workspaceId, $workspaceId,
$accessibleTenantIds, $accessibleTenantIds,
) )
->dashboardNeedsFollowUp() ->terminalFollowUp()
->latest('created_at') ->selectRaw('tenant_id, count(*) as aggregate_count')
->get() ->groupBy('tenant_id')
->groupBy(static fn (OperationRun $run): int => (int) $run->tenant_id); ->pluck('aggregate_count', 'tenant_id')
->map(static fn (mixed $count): int => (int) $count)
->all();
$followUpCounts = $followUpRuns $staleAttentionCounts = $this->scopeToVisibleTenants(
->map(static fn (Collection $runs): int => $runs->count()) OperationRun::query(),
$workspaceId,
$accessibleTenantIds,
)
->activeStaleAttention()
->selectRaw('tenant_id, count(*) as aggregate_count')
->groupBy('tenant_id')
->pluck('aggregate_count', 'tenant_id')
->map(static fn (mixed $count): int => (int) $count)
->all(); ->all();
$activeOperationCounts = $this->scopeToVisibleTenants( $activeOperationCounts = $this->scopeToVisibleTenants(
@ -218,7 +235,7 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
: []; : [];
return $accessibleTenants return $accessibleTenants
->map(function (Tenant $tenant) use ($followUpCounts, $followUpRuns, $activeOperationCounts, $alertFailureCounts): array { ->map(function (Tenant $tenant) use ($terminalFollowUpCounts, $staleAttentionCounts, $activeOperationCounts, $alertFailureCounts): array {
$tenantId = (int) $tenant->getKey(); $tenantId = (int) $tenant->getKey();
$aggregate = $this->governanceAggregate($tenant); $aggregate = $this->governanceAggregate($tenant);
@ -226,8 +243,8 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
'tenant' => $tenant, 'tenant' => $tenant,
'aggregate' => $aggregate, 'aggregate' => $aggregate,
'has_governance_attention' => $this->hasGovernanceAttention($aggregate), 'has_governance_attention' => $this->hasGovernanceAttention($aggregate),
'follow_up_operations_count' => (int) ($followUpCounts[$tenantId] ?? 0), 'terminal_follow_up_operations_count' => (int) ($terminalFollowUpCounts[$tenantId] ?? 0),
'latest_follow_up_run' => $followUpRuns->get($tenantId)?->first(), 'stale_attention_operations_count' => (int) ($staleAttentionCounts[$tenantId] ?? 0),
'active_operations_count' => (int) ($activeOperationCounts[$tenantId] ?? 0), 'active_operations_count' => (int) ($activeOperationCounts[$tenantId] ?? 0),
'alert_failures_count' => (int) ($alertFailureCounts[$tenantId] ?? 0), 'alert_failures_count' => (int) ($alertFailureCounts[$tenantId] ?? 0),
]; ];
@ -281,16 +298,16 @@ private function attentionItems(
CanonicalNavigationContext $navigationContext, CanonicalNavigationContext $navigationContext,
): array { ): array {
$items = collect($tenantContexts) $items = collect($tenantContexts)
->map(function (array $context) use ($user, $canViewAlerts, $navigationContext): ?array { ->flatMap(function (array $context) use ($user, $canViewAlerts, $navigationContext): array {
$tenant = $context['tenant'] ?? null; $tenant = $context['tenant'] ?? null;
$aggregate = $context['aggregate'] ?? null; $aggregate = $context['aggregate'] ?? null;
if (! $tenant instanceof Tenant || ! $aggregate instanceof TenantGovernanceAggregate) { if (! $tenant instanceof Tenant || ! $aggregate instanceof TenantGovernanceAggregate) {
return null; return [];
} }
if ($aggregate->lapsedGovernanceCount > 0) { if ($aggregate->lapsedGovernanceCount > 0) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_lapsed_governance', key: 'tenant_lapsed_governance',
family: 'governance', family: 'governance',
@ -305,11 +322,11 @@ private function attentionItems(
badgeColor: 'danger', badgeColor: 'danger',
destination: $this->tenantDashboardTarget($tenant, $user, 'Open tenant dashboard'), destination: $this->tenantDashboardTarget($tenant, $user, 'Open tenant dashboard'),
supportingMessage: 'Open the tenant dashboard to review the full invalid-governance family without narrowing the findings set.', supportingMessage: 'Open the tenant dashboard to review the full invalid-governance family without narrowing the findings set.',
); )];
} }
if ($aggregate->overdueOpenFindingsCount > 0) { if ($aggregate->overdueOpenFindingsCount > 0) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_overdue_findings', key: 'tenant_overdue_findings',
family: 'findings', family: 'findings',
@ -327,11 +344,11 @@ private function attentionItems(
user: $user, user: $user,
filters: ['tab' => 'overdue'], filters: ['tab' => 'overdue'],
), ),
); )];
} }
if ($this->shouldPromoteCompareAttention($aggregate)) { if ($this->shouldPromoteCompareAttention($aggregate)) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_compare_attention', key: 'tenant_compare_attention',
family: 'compare', family: 'compare',
@ -342,11 +359,11 @@ private function attentionItems(
badgeColor: $aggregate->tone, badgeColor: $aggregate->tone,
destination: $this->baselineCompareTarget($tenant, $user), destination: $this->baselineCompareTarget($tenant, $user),
supportingMessage: $aggregate->supportingMessage, supportingMessage: $aggregate->supportingMessage,
); )];
} }
if ($aggregate->highSeverityActiveFindingsCount > 0) { if ($aggregate->highSeverityActiveFindingsCount > 0) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_high_severity_findings', key: 'tenant_high_severity_findings',
family: 'findings', family: 'findings',
@ -364,11 +381,11 @@ private function attentionItems(
user: $user, user: $user,
filters: ['tab' => 'needs_action', 'high_severity' => true], filters: ['tab' => 'needs_action', 'high_severity' => true],
), ),
); )];
} }
if ($aggregate->expiringGovernanceCount > 0) { if ($aggregate->expiringGovernanceCount > 0) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_expiring_governance', key: 'tenant_expiring_governance',
family: 'governance', family: 'governance',
@ -389,33 +406,71 @@ private function attentionItems(
'governance_validity' => FindingException::VALIDITY_EXPIRING, 'governance_validity' => FindingException::VALIDITY_EXPIRING,
], ],
), ),
); )];
} }
$followUpOperationsCount = (int) ($context['follow_up_operations_count'] ?? 0); $items = [];
if ($followUpOperationsCount > 0) { $terminalFollowUpOperationsCount = (int) ($context['terminal_follow_up_operations_count'] ?? 0);
return $this->makeAttentionItem(
if ($terminalFollowUpOperationsCount > 0) {
$items[] = $this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_operations_follow_up', key: 'tenant_operations_terminal_follow_up',
family: 'operations', family: 'operations',
urgency: 'medium', urgency: 'medium',
title: 'Operations need follow-up', title: 'Terminal operations need follow-up',
body: sprintf( body: sprintf(
'%d run%s failed, completed with warnings, or still need operator follow-up.', '%d run%s finished blocked, partially, failed, or were automatically reconciled.',
$followUpOperationsCount, $terminalFollowUpOperationsCount,
$followUpOperationsCount === 1 ? '' : 's', $terminalFollowUpOperationsCount === 1 ? '' : 's',
), ),
badge: 'Operations', badge: 'Operations',
badgeColor: 'danger', badgeColor: 'danger',
destination: $this->operationsIndexTarget($tenant, $navigationContext, 'blocked'), destination: $this->operationsIndexTarget(
$tenant,
$navigationContext,
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
'Open terminal follow-up',
),
); );
} }
$staleAttentionOperationsCount = (int) ($context['stale_attention_operations_count'] ?? 0);
if ($staleAttentionOperationsCount > 0) {
$items[] = $this->makeAttentionItem(
tenant: $tenant,
key: 'tenant_operations_stale_attention',
family: 'operations',
urgency: 'medium',
title: 'Active operations look stale',
body: sprintf(
'%d run%s are still marked active but are past the lifecycle window.',
$staleAttentionOperationsCount,
$staleAttentionOperationsCount === 1 ? '' : 's',
),
badge: 'Operations',
badgeColor: 'warning',
destination: $this->operationsIndexTarget(
$tenant,
$navigationContext,
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
'Open stale operations',
),
);
}
if ($items !== []) {
return $items;
}
$activeOperationsCount = (int) ($context['active_operations_count'] ?? 0); $activeOperationsCount = (int) ($context['active_operations_count'] ?? 0);
if ($activeOperationsCount > 0) { if ($activeOperationsCount > 0) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_active_operations', key: 'tenant_active_operations',
family: 'operations', family: 'operations',
@ -429,13 +484,13 @@ private function attentionItems(
badge: 'Operations', badge: 'Operations',
badgeColor: 'warning', badgeColor: 'warning',
destination: $this->operationsIndexTarget($tenant, $navigationContext, 'active'), destination: $this->operationsIndexTarget($tenant, $navigationContext, 'active'),
); )];
} }
$alertFailuresCount = (int) ($context['alert_failures_count'] ?? 0); $alertFailuresCount = (int) ($context['alert_failures_count'] ?? 0);
if ($canViewAlerts && $alertFailuresCount > 0) { if ($canViewAlerts && $alertFailuresCount > 0) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_alert_delivery_failures', key: 'tenant_alert_delivery_failures',
family: 'alerts', family: 'alerts',
@ -449,12 +504,11 @@ private function attentionItems(
badge: 'Alerts', badge: 'Alerts',
badgeColor: 'danger', badgeColor: 'danger',
destination: $this->alertsOverviewTarget($navigationContext, true), destination: $this->alertsOverviewTarget($navigationContext, true),
); )];
} }
return null; return [];
}) })
->filter()
->values() ->values()
->all(); ->all();
@ -480,7 +534,8 @@ private function attentionPriority(array $item): int
'tenant_compare_attention' => 90, 'tenant_compare_attention' => 90,
'tenant_high_severity_findings' => 80, 'tenant_high_severity_findings' => 80,
'tenant_expiring_governance' => 70, 'tenant_expiring_governance' => 70,
'tenant_operations_follow_up' => 40, 'tenant_operations_stale_attention' => 45,
'tenant_operations_terminal_follow_up' => 40,
'tenant_active_operations' => 20, 'tenant_active_operations' => 20,
'tenant_alert_delivery_failures' => 10, 'tenant_alert_delivery_failures' => 10,
default => 0, default => 0,
@ -625,11 +680,6 @@ private function recentOperations(
array $accessibleTenantIds, array $accessibleTenantIds,
CanonicalNavigationContext $navigationContext, CanonicalNavigationContext $navigationContext,
): array { ): array {
$statusSpec = BadgeRenderer::label(BadgeDomain::OperationRunStatus);
$statusColorSpec = BadgeRenderer::color(BadgeDomain::OperationRunStatus);
$outcomeSpec = BadgeRenderer::label(BadgeDomain::OperationRunOutcome);
$outcomeColorSpec = BadgeRenderer::color(BadgeDomain::OperationRunOutcome);
return $this->scopeToAuthorizedTenants( return $this->scopeToAuthorizedTenants(
OperationRun::query()->with('tenant'), OperationRun::query()->with('tenant'),
$workspaceId, $workspaceId,
@ -638,17 +688,27 @@ private function recentOperations(
->latest('created_at') ->latest('created_at')
->limit(5) ->limit(5)
->get() ->get()
->map(function (OperationRun $run) use ($navigationContext, $statusSpec, $statusColorSpec, $outcomeSpec, $outcomeColorSpec): array { ->map(function (OperationRun $run) use ($navigationContext): array {
$destination = $this->operationDetailTarget($run, $navigationContext); $destination = $this->operationDetailTarget($run, $navigationContext);
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
]);
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $run->outcome,
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
]);
return [ return [
'id' => (int) $run->getKey(), 'id' => (int) $run->getKey(),
'title' => OperationCatalog::label((string) $run->type), 'title' => OperationCatalog::label((string) $run->type),
'tenant_label' => $run->tenant instanceof Tenant ? (string) $run->tenant->name : null, 'tenant_label' => $run->tenant instanceof Tenant ? (string) $run->tenant->name : null,
'status_label' => $statusSpec($run->status), 'status_label' => $statusSpec->label,
'status_color' => $statusColorSpec($run->status), 'status_color' => $statusSpec->color,
'outcome_label' => $outcomeSpec($run->outcome), 'outcome_label' => $outcomeSpec->label,
'outcome_color' => $outcomeColorSpec($run->outcome), 'outcome_color' => $outcomeSpec->color,
'lifecycle_label' => OperationUxPresenter::lifecycleAttentionSummary($run),
'guidance' => OperationUxPresenter::surfaceGuidance($run), 'guidance' => OperationUxPresenter::surfaceGuidance($run),
'started_at' => $run->created_at?->diffForHumans() ?? 'just now', 'started_at' => $run->created_at?->diffForHumans() ?? 'just now',
'destination' => $destination, 'destination' => $destination,
@ -665,6 +725,7 @@ private function calmnessState(
int $accessibleTenantCount, int $accessibleTenantCount,
array $attentionItems, array $attentionItems,
int $governanceAttentionTenantCount, int $governanceAttentionTenantCount,
int $totalProblemOperationsCount,
int $totalActiveOperationsCount, int $totalActiveOperationsCount,
int $totalAlertFailuresCount, int $totalAlertFailuresCount,
bool $canViewAlerts, bool $canViewAlerts,
@ -686,7 +747,9 @@ private function calmnessState(
]; ];
} }
$hasActivityAttention = $totalActiveOperationsCount > 0 || ($canViewAlerts && $totalAlertFailuresCount > 0); $hasActivityAttention = $totalActiveOperationsCount > 0
|| $totalProblemOperationsCount > 0
|| ($canViewAlerts && $totalAlertFailuresCount > 0);
$isCalm = $governanceAttentionTenantCount === 0 && ! $hasActivityAttention; $isCalm = $governanceAttentionTenantCount === 0 && ! $hasActivityAttention;
if ($isCalm) { if ($isCalm) {
@ -705,7 +768,7 @@ private function calmnessState(
'checked_domains' => $checkedDomains, 'checked_domains' => $checkedDomains,
'title' => 'Workspace activity still needs review', 'title' => 'Workspace activity still needs review',
'body' => 'This workspace is not calm in the domains it can check right now, but your current scope does not expose a more specific tenant drill-through here. Review operations first.', 'body' => 'This workspace is not calm in the domains it can check right now, but your current scope does not expose a more specific tenant drill-through here. Review operations first.',
'next_action' => $this->operationsIndexTarget(null, $navigationContext, 'blocked'), 'next_action' => $this->operationsIndexTarget(null, $navigationContext, 'active'),
]; ];
} }
@ -934,16 +997,18 @@ private function operationsIndexTarget(
?Tenant $tenant, ?Tenant $tenant,
CanonicalNavigationContext $navigationContext, CanonicalNavigationContext $navigationContext,
?string $activeTab = null, ?string $activeTab = null,
?string $problemClass = null,
string $label = 'Open operations', string $label = 'Open operations',
): array { ): array {
return $this->destination( return $this->destination(
kind: 'operations_index', kind: 'operations_index',
url: OperationRunLinks::index($tenant, $navigationContext, $activeTab, $tenant === null), url: OperationRunLinks::index($tenant, $navigationContext, $activeTab, $tenant === null, $problemClass),
label: $label, label: $label,
tenant: $tenant, tenant: $tenant,
filters: array_filter([ filters: array_filter([
'tenant_id' => $tenant?->getKey(), 'tenant_id' => $tenant?->getKey(),
'activeTab' => $activeTab, 'activeTab' => $activeTab,
'problemClass' => $problemClass,
], static fn (mixed $value): bool => $value !== null && $value !== ''), ], static fn (mixed $value): bool => $value !== null && $value !== ''),
); );
} }

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

@ -2,17 +2,24 @@
$state = $getState(); $state = $getState();
$state = is_array($state) ? $state : []; $state = is_array($state) ? $state : [];
$connectionState = is_string($state['state'] ?? null) ? (string) $state['state'] : 'needs_action'; $connectionState = is_string($state['state'] ?? null) ? (string) $state['state'] : 'missing';
$ctaUrl = is_string($state['cta_url'] ?? null) ? (string) $state['cta_url'] : '#'; $ctaUrl = is_string($state['cta_url'] ?? null) ? (string) $state['cta_url'] : '#';
$needsDefaultConnection = (bool) ($state['needs_default_connection'] ?? false);
$displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null; $displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null;
$provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null; $provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null;
$consentStatus = is_string($state['consent_status'] ?? null) ? (string) $state['consent_status'] : null;
$verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null;
$status = is_string($state['status'] ?? null) ? (string) $state['status'] : null; $status = is_string($state['status'] ?? null) ? (string) $state['status'] : null;
$healthStatus = is_string($state['health_status'] ?? null) ? (string) $state['health_status'] : null; $healthStatus = is_string($state['health_status'] ?? null) ? (string) $state['health_status'] : null;
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null; $lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null; $lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
$isMissing = $connectionState === 'needs_action'; $isMissing = $connectionState === 'missing';
$consentSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $consentStatus);
$verificationSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $verificationStatus);
$legacyStatusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, $status);
$legacyHealthSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, $healthStatus);
@endphp @endphp
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm"> <div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
@ -20,7 +27,9 @@
<div> <div>
<div class="text-sm font-semibold text-gray-800">Provider connection</div> <div class="text-sm font-semibold text-gray-800">Provider connection</div>
@if ($isMissing) @if ($isMissing)
<div class="mt-1 text-sm text-amber-700">Needs action: no default Microsoft provider connection is configured.</div> <div class="mt-1 text-sm text-amber-700">Needs action: no Microsoft provider connection is configured.</div>
@elseif ($needsDefaultConnection)
<div class="mt-1 text-sm text-amber-700">Needs action: set a default Microsoft provider connection.</div>
@else @else
<div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div> <div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div>
@endif @endif
@ -32,18 +41,32 @@
</div> </div>
@unless ($isMissing) @unless ($isMissing)
@if ($needsDefaultConnection && $displayName)
<div class="text-sm text-gray-700">
Current connection: <span class="font-medium">{{ $displayName }}</span>
</div>
@endif
<dl class="grid grid-cols-1 gap-2 text-sm text-gray-700 sm:grid-cols-2"> <dl class="grid grid-cols-1 gap-2 text-sm text-gray-700 sm:grid-cols-2">
<div> <div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt> <dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
<dd>{{ $provider ?? 'n/a' }}</dd> <dd>{{ $provider ?? 'n/a' }}</dd>
</div> </div>
<div> <div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Status</dt> <dt class="text-xs uppercase tracking-wide text-gray-500">Consent</dt>
<dd>{{ $status ?? 'n/a' }}</dd> <dd>
<x-filament::badge :color="$consentSpec->color" :icon="$consentSpec->icon" size="sm">
{{ $consentSpec->label }}
</x-filament::badge>
</dd>
</div> </div>
<div> <div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Health</dt> <dt class="text-xs uppercase tracking-wide text-gray-500">Verification</dt>
<dd>{{ $healthStatus ?? 'n/a' }}</dd> <dd>
<x-filament::badge :color="$verificationSpec->color" :icon="$verificationSpec->icon" size="sm">
{{ $verificationSpec->label }}
</x-filament::badge>
</dd>
</div> </div>
<div> <div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Last check</dt> <dt class="text-xs uppercase tracking-wide text-gray-500">Last check</dt>
@ -51,10 +74,32 @@
</div> </div>
</dl> </dl>
@if ($lastErrorReason) <div class="space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700">
<div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800"> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Diagnostics</div>
Last error reason: {{ $lastErrorReason }} <dl class="grid grid-cols-1 gap-2 sm:grid-cols-2">
</div> <div>
@endif <dt class="text-xs uppercase tracking-wide text-gray-500">Legacy status</dt>
<dd>
<x-filament::badge :color="$legacyStatusSpec->color" :icon="$legacyStatusSpec->icon" size="sm">
{{ $legacyStatusSpec->label }}
</x-filament::badge>
</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy health</dt>
<dd>
<x-filament::badge :color="$legacyHealthSpec->color" :icon="$legacyHealthSpec->icon" size="sm">
{{ $legacyHealthSpec->label }}
</x-filament::badge>
</dd>
</div>
</dl>
@if ($lastErrorReason)
<div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800">
Last error reason: {{ $lastErrorReason }}
</div>
@endif
</div>
@endunless @endunless
</div> </div>

View File

@ -1,16 +1,121 @@
<x-filament-panels::page> <x-filament-panels::page>
@php
$summary = $this->coverageSummary();
$basis = $this->basisRunSummary();
@endphp
<x-filament::section> <x-filament::section>
<div class="flex flex-col gap-3"> <div class="grid gap-4 xl:grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)]">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100"> <div class="space-y-4">
Searchable support matrix <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>
<div class="text-sm text-gray-600 dark:text-gray-300"> <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">
Search by policy type or label, sort the primary columns, and filter the runtime-derived coverage matrix without leaving the tenant inventory workspace. <div class="space-y-3">
</div> <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"> <div class="text-base font-semibold text-gray-950 dark:text-white">
Coverage rows combine supported policy types and foundations in a single read-only table so Segment and Dependencies stay easy to scan. {{ $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>
</div> </div>
</x-filament::section> </x-filament::section>

View File

@ -1,5 +1,7 @@
<x-filament-panels::page> <x-filament-panels::page>
@php($lifecycleSummary = $this->lifecycleVisibilitySummary()) @php($lifecycleSummary = $this->lifecycleVisibilitySummary())
@php($staleAttentionTab = \App\Models\OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION)
@php($terminalFollowUpTab = \App\Models\OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
<x-filament::tabs label="Operations tabs"> <x-filament::tabs label="Operations tabs">
<x-filament::tabs.item <x-filament::tabs.item
@ -15,10 +17,16 @@
Active Active
</x-filament::tabs.item> </x-filament::tabs.item>
<x-filament::tabs.item <x-filament::tabs.item
:active="$this->activeTab === 'blocked'" :active="$this->activeTab === $staleAttentionTab"
wire:click="$set('activeTab', 'blocked')" wire:click="$set('activeTab', '{{ $staleAttentionTab }}')"
> >
Needs follow-up Likely stale
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$this->activeTab === $terminalFollowUpTab"
wire:click="$set('activeTab', '{{ $terminalFollowUpTab }}')"
>
Terminal follow-up
</x-filament::tabs.item> </x-filament::tabs.item>
<x-filament::tabs.item <x-filament::tabs.item
:active="$this->activeTab === 'succeeded'" :active="$this->activeTab === 'succeeded'"
@ -42,8 +50,8 @@
@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0) @if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0)
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"> <div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ ($lifecycleSummary['likely_stale'] ?? 0) }} active operation(s) are beyond their lifecycle window. {{ ($lifecycleSummary['likely_stale'] ?? 0) }} active operation(s) are beyond their lifecycle window and belong in the stale-attention view.
{{ ($lifecycleSummary['reconciled'] ?? 0) }} operation(s) have already been automatically reconciled. {{ ($lifecycleSummary['reconciled'] ?? 0) }} operation(s) already carry reconciled stale lineage and belong in terminal follow-up.
</div> </div>
@endif @endif

View File

@ -4,20 +4,26 @@
$statusSpec = \App\Support\Badges\BadgeRenderer::spec( $statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunStatus, \App\Support\Badges\BadgeDomain::OperationRunStatus,
(string) $run->status, [
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
); );
$outcomeSpec = (string) $run->status === 'completed' $outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
? \App\Support\Badges\BadgeRenderer::spec( \App\Support\Badges\BadgeDomain::OperationRunOutcome,
\App\Support\Badges\BadgeDomain::OperationRunOutcome, [
(string) $run->outcome, 'outcome' => (string) $run->outcome,
) 'status' => (string) $run->status,
: null; 'freshness_state' => $run->freshnessState()->value,
],
);
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : []; $summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
$hasSummary = count($summaryCounts) > 0; $hasSummary = count($summaryCounts) > 0;
$integrityNote = \App\Support\RedactionIntegrity::noteForRun($run); $integrityNote = \App\Support\RedactionIntegrity::noteForRun($run);
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run); $guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
$decisionTruth = \App\Support\OpsUx\OperationUxPresenter::decisionZoneTruth($run);
@endphp @endphp
<x-filament-panels::page> <x-filament-panels::page>
@ -104,6 +110,47 @@
</dl> </dl>
</x-filament::section> </x-filament::section>
<x-filament::section>
<x-slot name="heading">
Current lifecycle truth
</x-slot>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Still active</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ ($decisionTruth['isCurrentlyActive'] ?? false) ? 'Yes' : 'No' }}
</div>
</div>
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Automatic reconciliation</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ ($decisionTruth['isReconciled'] ?? false) ? 'Yes' : 'No' }}
</div>
</div>
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Problem class</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ $decisionTruth['problemClassLabel'] ?? 'None' }}
</div>
</div>
</div>
@if (filled($decisionTruth['attentionNote'] ?? null))
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ $decisionTruth['attentionNote'] }}
</div>
@endif
@if (filled($decisionTruth['staleLineageNote'] ?? null))
<div class="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
{{ $decisionTruth['staleLineageNote'] }}
</div>
@endif
</x-filament::section>
@if ($integrityNote) @if ($integrityNote)
<x-filament::section> <x-filament::section>
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"> <div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">

View File

@ -17,12 +17,20 @@
@php @php
$statusSpec = \App\Support\Badges\BadgeRenderer::spec( $statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunStatus, \App\Support\Badges\BadgeDomain::OperationRunStatus,
(string) $run->status, [
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
); );
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec( $outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunOutcome, \App\Support\Badges\BadgeDomain::OperationRunOutcome,
(string) $run->outcome, [
'outcome' => (string) $run->outcome,
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
); );
$lifecycleAttention = \App\Support\OpsUx\OperationUxPresenter::lifecycleAttentionSummary($run);
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run); $guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
@endphp @endphp
<li class="flex items-center justify-between gap-3 py-2"> <li class="flex items-center justify-between gap-3 py-2">
@ -38,6 +46,11 @@
<x-filament::badge :color="$outcomeSpec->color" size="sm"> <x-filament::badge :color="$outcomeSpec->color" size="sm">
{{ $outcomeSpec->label }} {{ $outcomeSpec->label }}
</x-filament::badge> </x-filament::badge>
@if ($lifecycleAttention)
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
{{ $lifecycleAttention }}
</span>
@endif
</div> </div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">

View File

@ -27,12 +27,12 @@
<x-filament::section <x-filament::section
heading="Verification report" heading="Verification report"
description="Latest verification state for this tenant (DB-only rendering)." description="Latest stored verification result for this tenant. Consent and connection configuration are summarized separately above."
> >
<div class="space-y-4"> <div class="space-y-4">
@if ($run === null) @if ($run === null)
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300"> <div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
No verification operation has been started yet. No provider verification check has been recorded yet.
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View File

@ -54,6 +54,11 @@
<x-filament::badge :color="$operation['outcome_color']" size="sm"> <x-filament::badge :color="$operation['outcome_color']" size="sm">
{{ $operation['outcome_label'] }} {{ $operation['outcome_label'] }}
</x-filament::badge> </x-filament::badge>
@if (filled($operation['lifecycle_label'] ?? null))
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
{{ $operation['lifecycle_label'] }}
</span>
@endif
</div> </div>
@if (filled($operation['guidance'] ?? null)) @if (filled($operation['guidance'] ?? null))

View File

@ -9,6 +9,9 @@
x-data="opsUxProgressWidgetPoller()" x-data="opsUxProgressWidgetPoller()"
x-init="init()" x-init="init()"
wire:key="ops-ux-progress-widget" wire:key="ops-ux-progress-widget"
@if (! $disabled && $hasActiveRuns)
wire:poll.10s="refreshRuns"
@endif
> >
@if($runs->isNotEmpty()) @if($runs->isNotEmpty())
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;"> <div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">

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,36 @@
# Specification Quality Checklist: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
**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
- Validated after the initial draft. The spec stays focused on operator trust, cross-surface truth alignment, and drill-through continuity while explicitly avoiding new persistence or a lifecycle-model rewrite.
- The only new semantic split is a derived operator-facing distinction between terminal follow-up and active stale or stuck attention, built from existing lifecycle truth rather than new stored state.
- No clarification markers remain. Scope, non-goals, dependencies, and measurable outcomes are explicit enough to proceed to planning.

View File

@ -0,0 +1,279 @@
openapi: 3.1.0
info:
title: Operations Truth Alignment Contract
version: 1.0.0
summary: Route and UI truth contract for Spec 178.
components:
schemas:
FreshnessState:
type: string
enum:
- fresh_active
- likely_stale
- reconciled_failed
- terminal_normal
- unknown
ProblemClass:
type: string
enum:
- none
- active_stale_attention
- terminal_follow_up
OperationsDrillthroughState:
type: object
required:
- workspace_id
- problemClass
properties:
workspace_id:
type: integer
tenant_id:
type:
- integer
- 'null'
problemClass:
$ref: '#/components/schemas/ProblemClass'
activeTab:
type:
- string
- 'null'
navigationContext:
type:
- string
- 'null'
RunDecisionZoneTruth:
type: object
required:
- freshnessState
- problemClass
- isCurrentlyActive
- isReconciled
- primaryNextAction
properties:
freshnessState:
$ref: '#/components/schemas/FreshnessState'
problemClass:
$ref: '#/components/schemas/ProblemClass'
isCurrentlyActive:
type: boolean
isReconciled:
type: boolean
staleLineageNote:
type:
- string
- 'null'
primaryNextAction:
type: string
paths:
/admin:
get:
operationId: viewWorkspaceOverview
summary: Display the workspace overview with aligned operations attention and recency truth.
responses:
'200':
description: Workspace overview rendered successfully.
'404':
description: User is not entitled to the current workspace scope.
x-embedded-surfaces:
workspaceOperationsAttention:
surfaceType: embedded_attention_summary
problemBuckets:
- active_stale_attention
- terminal_follow_up
canonicalCollectionRoute: /admin/operations
destinationContract:
$ref: '#/components/schemas/OperationsDrillthroughState'
workspaceRecentOperations:
surfaceType: diagnostic_recency_table
canonicalCollectionRoute: /admin/operations
canonicalDetailRoute: /admin/operations/{run}
rowTruth:
freshnessState:
$ref: '#/components/schemas/FreshnessState'
problemClass:
$ref: '#/components/schemas/ProblemClass'
/admin/t/{tenant}:
get:
operationId: viewTenantDashboard
summary: Display the tenant dashboard with aligned operations attention, recent activity, and local progress truth.
parameters:
- name: tenant
in: path
required: true
schema:
type: string
responses:
'200':
description: Tenant dashboard rendered successfully.
'404':
description: User is not entitled to the tenant scope.
x-embedded-surfaces:
tenantOperationsAttention:
surfaceType: embedded_attention_summary
problemBuckets:
- active_stale_attention
- terminal_follow_up
canonicalCollectionRoute: /admin/operations
destinationContract:
$ref: '#/components/schemas/OperationsDrillthroughState'
tenantRecentOperations:
surfaceType: diagnostic_recency_table
canonicalCollectionRoute: /admin/operations
canonicalDetailRoute: /admin/operations/{run}
rowTruth:
freshnessState:
$ref: '#/components/schemas/FreshnessState'
problemClass:
$ref: '#/components/schemas/ProblemClass'
bulkOperationProgress:
surfaceType: live_progress_indicator
activeOnly: true
polling:
interval: 10s
activeWhen: active runs exist for the current tenant
inactiveWhen: no relevant active runs remain
/admin/operations:
get:
operationId: listAdminOperations
summary: Display the canonical admin operations hub with problem-class-aware filtering.
parameters:
- name: tenant_id
in: query
required: false
schema:
type: integer
- name: problemClass
in: query
required: false
schema:
$ref: '#/components/schemas/ProblemClass'
- name: activeTab
in: query
required: false
schema:
type: string
responses:
'200':
description: Operations hub rendered successfully.
'404':
description: User is not entitled to the workspace or referenced tenant scope.
x-ui-surface:
surfaceType: read_only_registry_report
displayLabel: Operations
canonicalDetailRoute: /admin/operations/{run}
filters:
active_stale_attention:
definition: queued or running runs whose freshness state is likely_stale
terminal_follow_up:
definition: completed runs whose outcome is blocked, partially_succeeded, or failed; reconciled stale lineage remains visible
rowTruth:
freshnessState:
$ref: '#/components/schemas/FreshnessState'
problemClass:
$ref: '#/components/schemas/ProblemClass'
/admin/operations/{run}:
get:
operationId: viewAdminOperation
summary: Display the canonical admin run detail with decision-zone lifecycle truth.
parameters:
- name: run
in: path
required: true
schema:
type: integer
responses:
'200':
description: Canonical admin run detail rendered successfully.
'404':
description: User is not entitled to the workspace or referenced tenant scope.
x-ui-surface:
surfaceType: detail_first_operational
displayLabel: Operation
decisionZoneTruth:
$ref: '#/components/schemas/RunDecisionZoneTruth'
invariant:
- stale and reconciled lifecycle truth must be visible in the primary decision hierarchy
- problem class on the destination must confirm the problem class of the origin link
/system/ops/runs:
get:
operationId: listSystemOperations
summary: Display the platform-wide operations registry with stale/reconciled lineage visible.
responses:
'200':
description: System operations registry rendered successfully.
'403':
description: Authenticated platform user lacks operations view capability.
'404':
description: Wrong plane or inaccessible system surface.
x-ui-surface:
surfaceType: read_only_registry_report
displayLabel: Operations
canonicalDetailRoute: /system/ops/runs/{run}
rowTruth:
freshnessState:
$ref: '#/components/schemas/FreshnessState'
problemClass:
$ref: '#/components/schemas/ProblemClass'
staleLineageVisible: true
/system/ops/failures:
get:
operationId: listSystemFailures
summary: Display the platform failure registry with reconciled stale lineage visible on failed terminal runs.
responses:
'200':
description: System failures registry rendered successfully.
'403':
description: Authenticated platform user lacks operations view capability.
'404':
description: Wrong plane or inaccessible system surface.
x-ui-surface:
surfaceType: read_only_registry_report
displayLabel: Failed operations
canonicalDetailRoute: /system/ops/runs/{run}
filter:
baseOutcome: failed
invariant:
- failed runs that were auto-reconciled from stale state must visibly preserve that lineage
/system/ops/stuck:
get:
operationId: listSystemStuckOperations
summary: Display active queued/running operations that crossed the lifecycle stuck threshold.
responses:
'200':
description: System stuck registry rendered successfully.
'403':
description: Authenticated platform user lacks operations view capability.
'404':
description: Wrong plane or inaccessible system surface.
x-ui-surface:
surfaceType: read_only_registry_report
displayLabel: Stuck operations
canonicalDetailRoute: /system/ops/runs/{run}
filter:
problemClass: active_stale_attention
invariant:
- the page remains active-only and uses the same lifecycle-policy thresholds as admin stale detection
/system/ops/runs/{run}:
get:
operationId: viewSystemOperation
summary: Display the platform run detail confirming stale/reconciled lineage and next action.
parameters:
- name: run
in: path
required: true
schema:
type: integer
responses:
'200':
description: System run detail rendered successfully.
'403':
description: Authenticated platform user lacks operations view capability.
'404':
description: Wrong plane or inaccessible system surface.
x-ui-surface:
surfaceType: detail_first_operational
displayLabel: Operation
decisionZoneTruth:
$ref: '#/components/schemas/RunDecisionZoneTruth'
invariant:
- stale/reconciled lineage remains visible even when the run is already terminal

View File

@ -0,0 +1,230 @@
# Phase 1 Data Model: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
## Overview
This feature does not add a table, persisted summary entity, or new lifecycle state machine. It aligns existing `OperationRun` truth and existing freshness/reconciliation semantics across multiple operator surfaces by introducing a small set of derived cross-surface contracts.
The central rule is unchanged: `OperationRun` is the only canonical lifecycle source of truth. Everything else in this slice is derived from status, outcome, freshness, and reconciliation metadata already present in the repo.
## Persistent Source Truths
### OperationRun
**Purpose**: Canonical operational record for queued, running, completed, stale, and automatically reconciled work shown across tenant, workspace, canonical admin, and system monitoring surfaces.
**Key fields**:
- `id`
- `workspace_id`
- `tenant_id`
- `type`
- `status`
- `outcome`
- `initiator_name`
- `summary_counts`
- `failure_summary`
- `context`
- `created_at`
- `started_at`
- `completed_at`
**Validation rules**:
- `status` and `outcome` remain service-owned; this feature must not introduce page-local lifecycle mutation.
- Every covered summary, list, detail, and notification surface must derive from the same run record and the same underlying lifecycle fields.
- The feature must not add a second persisted problem-state field or a new lifecycle table.
### Reconciliation Context (within `OperationRun.context`)
**Purpose**: Existing stored lineage that indicates a run was automatically reconciled after lifecycle drift.
**Expected fields**:
- `reconciled_at`
- `reason`
- `reason_code`
- `source`
**Validation rules**:
- Reconciliation context remains nested under `context.reconciliation`; this feature does not move it or normalize it into a new table.
- If reconciliation context exists, surfaces must preserve that stale/reconciled lineage within one navigation step of the canonical run truth.
## Existing Runtime Source Objects
### OperationRunFreshnessState
**Purpose**: Existing derived lifecycle interpretation for one run.
**Cases**:
- `fresh_active`
- `likely_stale`
- `reconciled_failed`
- `terminal_normal`
- `unknown`
**Validation rules**:
- This remains the canonical freshness interpretation for the slice.
- Spec 178 must not introduce a parallel freshness-state family.
### OperationLifecyclePolicy
**Purpose**: Existing threshold policy for deciding when queued/running work becomes stale.
**Consumed fields**:
- covered operation types
- queued stale threshold
- running stale threshold
**Validation rules**:
- Tenant, workspace, admin, and system stale or stuck semantics must all derive from the same underlying lifecycle-policy thresholds.
### StuckRunClassifier
**Purpose**: Existing system-panel classifier for active queued/running runs that crossed the stuck threshold.
**Validation rules**:
- `Stuck` remains an active-stale registry surface.
- System visibility for reconciled stale runs must be preserved through adjacent system surfaces rather than a new classifier or new stored state.
### OperationUxPresenter
**Purpose**: Existing operator-facing seam for guidance, notification wording, and run presentation.
**Validation rules**:
- New stale/reconciled wording should flow through this seam or a compatible existing presentation seam rather than widget-local strings.
## Derived Cross-Surface Contracts
### ProblemClassContract
**Purpose**: The thin operator-facing split used to align summary buckets, monitoring filters, local progress removal rules, and entry-point wording.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `freshnessState` | enum | yes | Existing `OperationRunFreshnessState` value |
| `problemClass` | enum | yes | `none`, `active_stale_attention`, or `terminal_follow_up` |
| `staleLineage` | boolean | yes | Whether the run is terminal but carries stale/reconciled history |
| `isCurrentlyActive` | boolean | yes | Whether the run should still be treated as actively executing |
| `requiresOperatorReview` | boolean | yes | Whether the surface should escalate the run into an attention/follow-up bucket |
**Validation rules**:
- `freshnessState = fresh_active``problemClass = none`, `isCurrentlyActive = true`
- `freshnessState = likely_stale``problemClass = active_stale_attention`, `isCurrentlyActive = true`, `requiresOperatorReview = true`
- `freshnessState = reconciled_failed``problemClass = terminal_follow_up`, `staleLineage = true`, `isCurrentlyActive = false`, `requiresOperatorReview = true`
- `freshnessState = terminal_normal` and `outcome in {blocked, partially_succeeded, failed}``problemClass = terminal_follow_up`, `staleLineage = false`
- `freshnessState = terminal_normal` and healthy terminal outcome ⇒ `problemClass = none`
### OperationsAttentionBucket
**Purpose**: Shared contract for tenant/workspace operations attention summaries.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `problemClass` | enum | yes | `terminal_follow_up` or `active_stale_attention` |
| `count` | integer | yes | Bucket size |
| `label` | string | yes | Operator-facing bucket label |
| `destination` | object | yes | Canonical operations route plus tenant/workspace-safe filter state |
| `emptyAllowed` | boolean | yes | Whether the bucket may be hidden when count is zero |
**Validation rules**:
- Attention surfaces must not mix both problem classes into one undifferentiated bucket.
- Each bucket exposes one destination only.
### OperationsHubFilterState
**Purpose**: Structured state needed to keep `/admin/operations` semantically continuous when opened from a summary or notification.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `workspace_id` | integer | yes | Existing workspace scope |
| `tenant_id` | integer nullable | no | Active tenant filter when applicable |
| `problemClass` | enum | yes | `terminal_follow_up`, `active_stale_attention`, or `all` |
| `activeTab` | string nullable | no | Existing or extended visible tab/filter state |
| `navigationContext` | string nullable | no | Existing canonical back-link or page-context state |
**Validation rules**:
- Links opened because of stale active attention must land in a visibly stale-active view, not a mixed generic bucket.
- Links opened because of terminal follow-up must land in a visibly terminal-problem view.
### RecentOperationRowTruth
**Purpose**: Shared row-level contract for tenant/workspace recent-operation tables and admin/system list rendering.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `runId` | integer | yes | Canonical run identifier |
| `status` | string | yes | Existing lifecycle status |
| `outcome` | string | yes | Existing execution outcome |
| `freshnessState` | enum | yes | Existing freshness interpretation |
| `problemClass` | enum | yes | Derived attention class |
| `staleLineage` | boolean | yes | Whether the row should visibly indicate reconciled stale history |
| `guidance` | string nullable | no | Short operator-facing row hint |
| `destination` | object | yes | Canonical detail URL plus optional collection URL |
**Validation rules**:
- A recent-operations row must not visually imply healthy active progress when `problemClass = active_stale_attention` or `staleLineage = true`.
- Terminal/reconciled rows must remain distinguishable from healthy completed rows.
### BulkOperationProgressSnapshot
**Purpose**: Active-only overlay contract for local progress rendering.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `runId` | integer | yes | Run being shown |
| `tenantId` | integer | yes | Tenant scope for the overlay |
| `freshnessState` | enum | yes | Existing freshness interpretation |
| `displayAsActive` | boolean | yes | Whether the run should remain visible in the overlay |
| `shouldPoll` | boolean | yes | Whether the component should continue polling |
| `overflowCount` | integer | yes | Existing overflow behavior |
**Validation rules**:
- Only currently active runs may remain in the overlay.
- If a run becomes terminal or reconciled, `displayAsActive` must become `false` within one refresh cycle.
- `shouldPoll` must become `false` when no relevant active runs remain.
### RunDecisionZoneTruth
**Purpose**: Canonical detail contract for what the operator should learn first.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `freshnessState` | enum | yes | Existing freshness interpretation |
| `problemClass` | enum | yes | Derived attention class |
| `isCurrentlyActive` | boolean | yes | Plain answer to “is the run still active?” |
| `isReconciled` | boolean | yes | Whether automatic reconciliation already happened |
| `staleLineageNote` | string nullable | no | Visible explanation when terminal truth came from stale reconciliation |
| `primaryNextAction` | string | yes | First follow-up step the operator should take |
**Validation rules**:
- For `likely_stale` and `reconciled_failed`, this contract must be visible in the primary decision hierarchy rather than only in diagnostics.
- `primaryNextAction` must differ by problem class: infrastructure investigation for stale-active, follow-up/retry/artifact review for terminal problems.
### NotificationTruthPayload
**Purpose**: Minimal contract for completed notification wording and link continuity.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | yes | Operator-facing summary title |
| `problemClass` | enum | yes | Derived attention class |
| `staleLineage` | boolean | yes | Whether stale/reconciled history must be visible in wording |
| `runUrl` | string | yes | Canonical run destination |
**Validation rules**:
- Notification wording must not be calmer than the current run truth.
- If `staleLineage = true`, the notification must preserve that lineage in title or body without requiring the operator to infer it later.
## Relationships
- One `OperationRun` yields one `OperationRunFreshnessState` and one derived `ProblemClassContract`.
- Tenant/workspace attention buckets, recent-operation rows, admin monitoring filters, system monitoring lists, canonical detail, and completed notifications all consume the same derived problem-class contract.
- `BulkOperationProgressSnapshot` is a specialized active-only view over the same run truth.
- `RunDecisionZoneTruth` is the highest-trust detailed interpretation of the same run truth and should confirm the same problem class visible on summary surfaces.
## Lifecycle Notes
1. `OperationRun` remains the single persisted source of lifecycle truth.
2. Freshness is derived by the existing lifecycle policy and freshness-state enum.
3. Problem class is derived from freshness plus terminal outcome; it is not stored.
4. Summary surfaces consume the derived problem class in separate buckets.
5. The canonical operations hub consumes the derived problem class as visible filter state.
6. Local progress consumes the same truth but removes terminal/reconciled runs because it is active-only.
7. Canonical detail and notifications confirm the same truth with stronger operator guidance.

View File

@ -0,0 +1,312 @@
# Implementation Plan: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
**Branch**: `178-ops-truth-alignment` | **Date**: 2026-04-05 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/178-ops-truth-alignment/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/178-ops-truth-alignment/spec.md`
## Summary
Align operations truth across tenant dashboard summaries, workspace overview summaries, BulkOperationProgress, recent-operations widgets, the canonical admin monitoring hub, the canonical run-detail page, and the system-panel stuck or failure surfaces without changing the `OperationRun` schema, inventing a second lifecycle model, or widening authorization scope. The implementation stays narrow by reusing existing `OperationRun` status, outcome, freshness, reconciliation metadata, and route structure, then hardening four seams that already exist but drift independently today: summary bucketing, local progress freshness, canonical drill-through continuity, and decision-zone emphasis.
The first slice adds one shared derived problem-class contract on top of the existing lifecycle truth so all covered surfaces can separate `terminal follow-up` from `active stale/stuck attention` without creating new persistence. The second slice applies that contract to local progress and summary surfaces, aligns the admin and system monitoring surfaces around the same stale or reconciled story, and preserves that story through notifications and drill-throughs. Focused Pest coverage then locks in cross-surface truth, polling freshness, system visibility of reconciled stale lineage, and decision-zone emphasis.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages
**Storage**: PostgreSQL unchanged; existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change
**Testing**: Pest 4 feature and Livewire or Filament component tests through Laravel Sail, plus existing system-panel and monitoring guard coverage
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging/production
**Project Type**: web application
**Performance Goals**: keep tenant, workspace, admin, and system monitoring surfaces DB-only at render; converge local progress truth within one polling cycle after canonical state changes; poll only while relevant active runs exist; preserve existing 10-second active-surface polling cadence where polling is used
**Constraints**: no schema migration; no new persisted lifecycle truth; no enum rewrite; no new route family; no cross-plane leakage; no ad-hoc status or badge mappings; lifecycle transitions remain service-owned; system stuck truth must remain discoverable after reconciliation; no new panel assets or provider-registration changes
**Scale/Scope**: 8 operator-facing surfaces across tenant, workspace, canonical admin, and system panels plus existing operation notifications and shared presenter or query seams; one canonical lifecycle model reused across existing operation types
## 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 | No inventory, backup, or snapshot ownership semantics change. |
| Read/write separation | PASS | PASS | The slice is read-time truth alignment only; no new mutation path or action surface is introduced. |
| Graph contract path | N/A | N/A | No Microsoft Graph call path is touched. |
| Deterministic capabilities | PASS | PASS | Existing admin-plane and system-plane authorization remains authoritative. |
| RBAC-UX plane separation | PASS | PASS | `/admin` and `/system` remain separate; no cross-plane bypass is introduced. |
| Workspace + tenant isolation | PASS | PASS | Canonical admin routes remain workspace- and tenant-safe; system routes remain platform-scoped only. |
| Destructive confirmation standard | PASS | PASS | No new destructive action is introduced. Existing destructive flows remain governed by their originating features. |
| Global search safety | PASS | PASS | No global-search behavior changes are part of this slice. |
| Run observability / Ops-UX 3-surface contract | PASS | PASS | Existing `OperationRun` truth remains canonical; the feature changes presentation and polling seams only. |
| Ops lifecycle ownership | PASS | PASS | `OperationRun.status` and `OperationRun.outcome` remain service-owned; summary surfaces stay read-only. |
| Ops summary counts | PASS | PASS | No new `summary_counts` shape or key family is introduced. |
| Data minimization / DB-only render | PASS | PASS | Monitoring and dashboard surfaces remain DB-only and do not add render-time external calls. |
| Proportionality / no premature abstraction | PASS | PASS | The design reuses model scopes, presenter seams, and existing route helpers instead of adding a new lifecycle framework. |
| Persisted truth / behavioral state | PASS | PASS | No new table, persisted artifact, or top-level state family is added. |
| UI semantics / few layers | PASS | PASS | Only a thin derived problem-class split is introduced; it remains derived from existing lifecycle truth. |
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge and presenter seams remain authoritative for status, outcome, and freshness meaning. |
| Filament-native UI / Action Surface Contract | PASS | PASS | Existing widgets, tables, pages, and detail surfaces remain in place; no redundant inspect model is introduced. |
| Filament UX-001 | PASS | PASS | Existing detail and list hierarchies remain intact; stale or reconciled truth is elevated inside existing summary structures. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The feature stays inside the current Filament v5 + Livewire v4 stack. |
| Provider registration location | PASS | PASS | No panel or provider change is required; Laravel 11+ registration remains in `bootstrap/providers.php`. |
| Global-search hard rule | PASS | PASS | No globally searchable resource is added or altered. |
| Asset strategy | PASS | PASS | No new assets or `filament:assets` deployment changes are needed. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds business-truth regression coverage for alignment, visibility, and drill-through continuity rather than thin view-only tests. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/178-ops-truth-alignment/research.md`.
Key decisions:
- Reuse the existing `OperationRunFreshnessState`, lifecycle policy, reconciliation metadata, and `OperationRun` query scopes as the canonical lifecycle base instead of introducing a second lifecycle or problem-state model.
- Introduce a thin derived split between `terminal follow-up` and `active stale/stuck attention` through existing model or presenter seams rather than a new taxonomy framework.
- Extend the repo's current conditional polling pattern to `BulkOperationProgress` instead of introducing a new live-refresh mechanism.
- Keep `BulkOperationProgress` as an active-only surface: terminal or reconciled runs should disappear from the overlay within one refresh cycle, while recent and attention surfaces carry their follow-up semantics.
- Use `/admin/operations` as the sole canonical collection route and preserve problem-class continuity through filter or tab state rather than new routes.
- Keep `/system/ops/stuck` focused on active stale candidates, but make reconciled stale lineage explicitly discoverable on system runs, failures, and detail surfaces so the stale truth chain does not disappear after reconciliation.
- Elevate stale and reconciled lifecycle truth through the existing canonical decision-zone and guidance seams instead of a new detail surface or banner framework.
- Keep notification and entry-point changes narrow by extending the existing `OperationRunCompleted` / `OperationUxPresenter` path instead of redesigning the notification subsystem.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/178-ops-truth-alignment/`:
- `data-model.md`: existing persistent source truth plus the derived cross-surface truth contracts for this slice
- `contracts/operations-truth-alignment.openapi.yaml`: internal route and UI contract for summary buckets, canonical drill-through state, and system/admin monitoring continuity
- `quickstart.md`: focused implementation and verification workflow for admin-plane, system-plane, and local-progress truth alignment
Design decisions:
- `OperationRun` remains the only persistent lifecycle source of truth; the design uses existing freshness and reconciliation semantics rather than adding a new table or state family.
- The narrowest shared seam is an extension of existing `OperationRun` query scopes and `OperationUxPresenter`-style rendering helpers so dashboard, workspace, monitoring, detail, and notification surfaces can agree on one derived problem-class split.
- `BulkOperationProgress` gains the same conditional polling discipline already used elsewhere and remains an active-only affordance rather than becoming a second summary surface.
- Dashboard, workspace, and recent-operation surfaces carry problem-class-specific drill-through metadata into `/admin/operations`, where the admin monitoring hub becomes the single canonical collection route for both `terminal follow-up` and `active stale/stuck attention`.
- System monitoring stays within the current page family. `Stuck` remains active-stale focused, while `Runs`, `Failures`, and detail surfaces make reconciled stale lineage visible so operators can still recover the stale story after reconciliation.
- Canonical run detail hardening happens inside existing summary and decision-zone seams so stale/reconciled attention is promoted without changing routing or page ownership.
## Project Structure
### Documentation (this feature)
```text
specs/178-ops-truth-alignment/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── operations-truth-alignment.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ ├── Monitoring/
│ │ │ └── Operations.php
│ │ └── Operations/
│ │ └── TenantlessOperationRunViewer.php
│ ├── System/
│ │ └── Pages/
│ │ └── Ops/
│ │ ├── Runs.php
│ │ ├── Failures.php
│ │ ├── Stuck.php
│ │ └── ViewRun.php
│ └── Widgets/
│ ├── Dashboard/
│ │ ├── DashboardKpis.php
│ │ ├── NeedsAttention.php
│ │ └── RecentOperations.php
│ └── Workspace/
│ ├── WorkspaceNeedsAttention.php
│ └── WorkspaceRecentOperations.php
├── Livewire/
│ └── BulkOperationProgress.php
├── Models/
│ └── OperationRun.php
├── Notifications/
│ └── OperationRunCompleted.php
└── Support/
├── Operations/
│ ├── OperationLifecyclePolicy.php
│ └── OperationRunFreshnessState.php
├── OpsUx/
│ ├── ActiveRuns.php
│ └── OperationUxPresenter.php
├── SystemConsole/
│ └── StuckRunClassifier.php
├── Workspaces/
│ └── WorkspaceOverviewBuilder.php
└── OperationRunLinks.php
resources/
├── views/
│ ├── filament/
│ │ └── system/
│ │ └── pages/
│ │ └── ops/
│ │ └── view-run.blade.php
│ └── livewire/
│ └── bulk-operation-progress.blade.php
tests/
├── Feature/
│ ├── Filament/
│ │ ├── DashboardKpisWidgetTest.php
│ │ ├── NeedsAttentionWidgetTest.php
│ │ ├── RecentOperationsSummaryWidgetTest.php
│ │ └── WorkspaceOverviewOperationsTest.php
│ ├── Monitoring/
│ │ ├── MonitoringOperationsTest.php
│ │ ├── OperationsDashboardDrillthroughTest.php
│ │ ├── OperationsDbOnlyRenderTest.php
│ │ ├── OperationsDbOnlyTest.php
│ │ └── OperationsTenantScopeTest.php
│ ├── Notifications/
│ │ └── OperationRunNotificationTest.php
│ ├── OpsUx/
│ │ └── BulkOperationProgressDbOnlyTest.php
│ ├── System/
│ │ └── Spec114/
│ │ ├── CanonicalRunDetailTest.php
│ │ ├── OpsFailuresViewTest.php
│ │ ├── OpsStuckViewTest.php
│ │ └── OpsTriageActionsTest.php
│ ├── Guards/
│ │ └── ActionSurfaceContractTest.php
│ └── RunAuthorizationTenantIsolationTest.php
```
**Structure Decision**: Keep the existing Laravel monolith structure. The implementation should extend current model scopes, presenters, widgets, monitoring pages, and system pages instead of introducing a new operations-overview layer or new directory family.
## Implementation Strategy
### Phase A — Introduce One Shared Derived Problem-Class Contract
**Goal**: Let every covered surface derive the same operator-facing split between terminal follow-up and active stale/stuck attention from the existing lifecycle truth.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Models/OperationRun.php` | Add narrow query scopes or helpers for `terminal follow-up` and `active stale/stuck attention`, keeping `dashboardNeedsFollowUp()` as a compatibility umbrella if needed. |
| A.2 | `app/Support/OpsUx/OperationUxPresenter.php` and existing freshness helpers | Centralize derived problem-class text, stale-lineage wording, and row/detail/notification display decisions using existing freshness and reconciliation truth. |
| A.3 | `app/Support/OpsUx/ActiveRuns.php` | Extend or refine active-run polling helpers so summary and progress surfaces can poll only while relevant active runs still exist. |
### Phase B — Harden Local Progress And Summary Surfaces
**Goal**: Remove stale local-progress residue and separate terminal problems from active stale attention on tenant and workspace entry surfaces.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Livewire/BulkOperationProgress.php` and `resources/views/livewire/bulk-operation-progress.blade.php` | Add conditional polling, canonical refresh behavior, and active-only visibility so terminal or reconciled runs disappear within one refresh cycle. |
| B.2 | `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Widgets/Dashboard/NeedsAttention.php` | Split mixed operations follow-up into explicit terminal vs stale-active buckets with one matching destination each. |
| B.3 | `app/Filament/Widgets/Dashboard/RecentOperations.php` | Surface freshness/problem-class truth per row so stale candidates and terminal follow-up do not read like generic recent activity. |
| B.4 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, and `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php` | Mirror the same split and row semantics on workspace surfaces so workspace and tenant summaries speak the same truth grammar. |
### Phase C — Align Canonical Operations Hub And Drill-Through State
**Goal**: Make `/admin/operations` the canonical collection route for both problem classes and preserve the originating class through every entry point.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Filament/Pages/Monitoring/Operations.php` | Add or tighten problem-class-aware filters/tabs for `active stale/stuck attention` and `terminal follow-up` without creating a new monitoring page. |
| C.2 | `app/Support/OperationRunLinks.php` | Carry tenant-safe problem-class filter state and existing navigation context into canonical operations links from dashboard, workspace, and notification entry points. |
| C.3 | Existing summary and recent-operation surfaces | Replace broad `needs follow-up` drill-throughs with explicit problem-class destinations so the landing page confirms the originating operator story. |
### Phase D — Preserve Stale Truth Across Canonical And System Monitoring Surfaces
**Goal**: Keep stale/reconciled lineage visible to operators after auto-reconciliation and promote stale/reconciled truth inside the primary decision hierarchy.
| Step | File | Change |
|------|------|--------|
| D.1 | Existing canonical run-detail composition seams under `OperationRunResource` / `TenantlessOperationRunViewer` | Elevate likely-stale and reconciled lifecycle truth inside the existing decision-zone/current-state summary rather than leaving it only in secondary banners or diagnostics. |
| D.2 | `app/Filament/System/Pages/Ops/Runs.php`, `Failures.php`, `Stuck.php`, and `ViewRun.php` | Keep `Stuck` focused on active stale candidates while exposing reconciled stale lineage on system runs, failures, and detail so platform operators can still recover the stale story after reconciliation. |
| D.3 | `resources/views/filament/system/pages/ops/view-run.blade.php` | Strengthen stale/reconciled visual emphasis in the existing guidance/current-state rendering instead of adding a new detail surface. |
### Phase E — Keep Notifications And Entry-Point Wording Truthful
**Goal**: Ensure entry points never frame a run more calmly than its current lifecycle or freshness truth.
| Step | File | Change |
|------|------|--------|
| E.1 | `app/Notifications/OperationRunCompleted.php` and existing presenter seams | Preserve problem-class wording and stale-lineage emphasis in terminal notifications and linked entry-point copy. |
| E.2 | Existing dashboard/workspace/navigation copy | Keep `needs follow-up` as an umbrella only when the concrete sub-class remains visible and recoverable. |
### Phase F — Regression Protection And Verification
**Goal**: Lock the truth contract into tests and preserve DB-only rendering, authorization semantics, and system/admin continuity.
| Step | File | Change |
|------|------|--------|
| F.1 | `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `NeedsAttentionWidgetTest.php`, `RecentOperationsSummaryWidgetTest.php`, and `WorkspaceOverviewOperationsTest.php` | Add assertions for terminal-vs-stale separation, row truth, and workspace/tenant summary parity. |
| F.2 | `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php` | Prove polling freshness and active-only visibility without enqueue-event dependence. |
| F.3 | `tests/Feature/Monitoring/MonitoringOperationsTest.php`, `OperationsDashboardDrillthroughTest.php`, `OperationsDbOnlyRenderTest.php`, `OperationsDbOnlyTest.php`, `OperationsTenantScopeTest.php`, and `RunAuthorizationTenantIsolationTest.php` | Prove canonical hub filters, drill-through continuity, DB-only rendering, and tenant-safe access semantics. |
| F.4 | `tests/Feature/System/Spec114/CanonicalRunDetailTest.php`, `OpsFailuresViewTest.php`, `OpsStuckViewTest.php`, `OpsTriageActionsTest.php`, and `tests/Feature/Notifications/OperationRunNotificationTest.php` | Prove stale/reconciled visibility across system detail, failures, stuck surfaces, and notifications. |
| F.5 | `tests/Feature/Guards/ActionSurfaceContractTest.php` plus `vendor/bin/sail bin pint --dirty --format agent` | Preserve surface-contract compliance and formatting before implementation is considered complete. |
## Key Design Decisions
### D-001 — Preserve `OperationRunFreshnessState` as the lifecycle base and derive problem class above it
The repo already has a narrow, useful freshness enum. The plan builds the operator-facing split above that existing truth instead of adding a second enum or persisted state family.
### D-002 — Use existing model/presenter seams instead of a new taxonomy framework
The narrowest implementation is to extend `OperationRun` query scopes, `OperationUxPresenter`, and existing route helpers. A new cross-domain classification framework would be disproportionate to the problem.
### D-003 — Keep `BulkOperationProgress` active-only
The overlay should not become a second follow-up surface. Once a run is terminal or reconciled, it should leave the overlay and let recent/attention/canonical surfaces tell the rest of the story.
### D-004 — `/admin/operations` remains the only canonical collection route
Problem-class continuity should be achieved with filters/tabs and link state, not by introducing tenant-specific or class-specific duplicate routes.
### D-005 — `Stuck` remains active-stale focused, but stale lineage must survive reconciliation elsewhere in system monitoring
Widening `/system/ops/stuck` into a mixed registry would blur its purpose. The narrower design keeps active stale candidates there and makes reconciled stale lineage visible on `/system/ops/runs`, `/system/ops/failures`, and system detail.
### D-006 — Decision-zone emphasis is the canonical fix for stale/reconciled truth
The canonical detail surface already has the right ownership. The plan promotes stale/reconciled truth inside that existing hierarchy instead of inventing a new banner or side-panel architecture.
### D-007 — Notification changes stay on the existing `OperationRunCompleted` path
The feature should extend current terminal notification semantics, not create a new notification subsystem or duplicate entry-point logic elsewhere.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Summary surfaces still share labels but not identical filter meaning | High | Medium | Carry problem-class state through `OperationRunLinks` and verify destination continuity in focused tests. |
| Bulk progress stays live too long or polls too often | High | Medium | Reuse the existing conditional polling pattern and verify convergence/stoppage behavior in DB-only tests. |
| System surfaces lose stale lineage after reconciliation | High | Medium | Keep stale lineage explicit on system runs, failures, and detail even when `Stuck` remains active-only. |
| Canonical detail duplicates stale/reconciled emphasis in multiple equal-priority areas | Medium | Medium | Reuse the existing decision-zone hierarchy and verify emphasis through detail tests instead of adding parallel warning surfaces. |
| The thin derived split drifts into a new framework over time | Medium | Low | Keep it constrained to existing model scopes, presenter seams, route helpers, and regression coverage. |
## Test Strategy
- Extend tenant and workspace summary widget coverage so dashboard and workspace attention surfaces clearly separate terminal follow-up from active stale/stuck attention.
- Extend `BulkOperationProgressDbOnlyTest.php` to prove terminal/reconciled runs disappear within one refresh cycle and polling stops when no relevant active runs remain.
- Extend canonical monitoring tests so `/admin/operations` filters, row rendering, and drill-throughs preserve problem-class continuity and remain DB-only and tenant-safe.
- Extend system-panel tests so `/system/ops/stuck`, `/system/ops/failures`, `/system/ops/runs`, and system detail preserve stale/reconciled lineage in a way platform operators can recover.
- Extend notification tests so completed notifications and linked destinations do not frame reconciled or stale-derived terminal runs more calmly than current truth.
- Run focused Pest suites through Sail plus `vendor/bin/sail bin pint --dirty --format agent`; full-suite execution is not required for planning artifacts.
## Post-Design Constitution Re-check
- `PASS` The design keeps `OperationRun` as the only persistent lifecycle truth and introduces no new schema or state family.
- `PASS` The derived problem-class split remains narrow and stays within existing model, presenter, and route-helper seams.
- `PASS` Admin-plane and system-plane surfaces stay separated and tenant-safe; no cross-plane leakage path is introduced.
- `PASS` The run-detail emphasis work reuses existing decision-zone ownership instead of adding a second detail hierarchy.
- `PASS` No new destructive actions, global-search changes, asset registration, or provider registration changes are required.
- `PASS` Livewire v4 and Filament v5 compliance remains intact.
## Complexity Tracking
No constitution waiver is expected. This slice hardens shared truth semantics and removes drift without introducing new persistence, new orchestration, or a new semantic framework.

View File

@ -0,0 +1,155 @@
# Quickstart: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
## Goal
Validate that tenant, workspace, canonical admin, and system operations surfaces now tell the same lifecycle story for fresh active runs, likely stale active runs, reconciled stale runs, and terminal problem runs, and that local progress surfaces stop implying false activity within one polling cycle.
## Prerequisites
1. Start Sail.
2. Ensure you have one admin-plane user with workspace and tenant access and one platform user with system operations view access.
3. Seed or create run scenarios for:
- fresh active run
- likely stale active run
- automatically reconciled stale run
- terminal failed run
- terminal blocked or partially succeeded run
- healthy completed run
4. Ensure the seeded runs are visible through:
- tenant dashboard operations summary and recent operations
- workspace overview operations summary and recent operations
- `/admin/operations`
- `/admin/operations/{run}`
- `/system/ops/stuck`
- `/system/ops/failures`
- `/system/ops/runs/{run}`
5. Prepare at least one scenario where canonical truth changes from active to terminal or reconciled without a new enqueue event so local progress freshness can be verified.
## Implementation Validation Order
### 1. Run lifecycle and reconciliation baselines
```bash
vendor/bin/sail artisan test --compact tests/Feature/Console/ReconcileOperationRunsCommandTest.php
vendor/bin/sail artisan test --compact tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/MonitoringOperationsTest.php
```
Expected outcome:
- Existing stale/reconciliation semantics remain stable.
- Canonical monitoring still renders DB-only and tenant-safe.
### 2. Run tenant/workspace summary and recency coverage
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewOperationsTest.php
```
Expected outcome:
- Tenant and workspace summary surfaces separate terminal follow-up from active stale/stuck attention.
- Recent-operation rows distinguish fresh active, stale active, reconciled stale, and terminal follow-up truth.
### 3. Run local-progress freshness coverage
```bash
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php
```
Expected outcome:
- The overlay polls only while relevant active runs exist.
- Terminal or reconciled runs disappear within one refresh cycle even without a new enqueue event.
### 4. Run canonical admin monitoring and drill-through coverage
```bash
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDbOnlyTest.php
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsTenantScopeTest.php
vendor/bin/sail artisan test --compact tests/Feature/RunAuthorizationTenantIsolationTest.php
```
Expected outcome:
- `/admin/operations` preserves tenant-safe problem-class continuity from dashboard/workspace entry points.
- Canonical monitoring remains DB-only and authorization-safe.
### 5. Run system-panel visibility coverage
```bash
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/CanonicalRunDetailTest.php
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsFailuresViewTest.php
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsStuckViewTest.php
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsTriageActionsTest.php
```
Expected outcome:
- `/system/ops/stuck` keeps active stale candidates visible.
- `/system/ops/failures` and system detail preserve stale/reconciled lineage for terminal runs that were auto-reconciled.
### 6. Run notification and surface-guard coverage
```bash
vendor/bin/sail artisan test --compact tests/Feature/Notifications/OperationRunNotificationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
```
Expected outcome:
- Terminal notifications and linked destinations do not frame reconciled or stale-derived terminal runs more calmly than current truth.
- Changed surfaces still satisfy their action-surface contracts.
### 7. Format touched files
```bash
vendor/bin/sail bin pint --dirty --format agent
```
Expected outcome:
- Changed files conform to project formatting rules.
### 8. Re-run the final focused verification pack
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewOperationsTest.php
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/MonitoringOperationsTest.php
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDbOnlyTest.php
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsTenantScopeTest.php
vendor/bin/sail artisan test --compact tests/Feature/RunAuthorizationTenantIsolationTest.php
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/CanonicalRunDetailTest.php
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsFailuresViewTest.php
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsStuckViewTest.php
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsTriageActionsTest.php
vendor/bin/sail artisan test --compact tests/Feature/Notifications/OperationRunNotificationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
```
Expected outcome:
- Cross-surface truth alignment, stale-lineage visibility, local-progress freshness, and drill-through continuity all remain stable after formatting.
## Manual Smoke Check
1. Open `/admin/t/{tenant}` for seeded tenant scenarios representing fresh active, likely stale active, reconciled stale, terminal failure, and healthy completion.
2. Confirm tenant summary surfaces do not mix terminal follow-up with active stale/stuck attention.
3. Leave BulkOperationProgress visible while one run transitions from active to terminal or reconciled without a new enqueue event and confirm the overlay stops showing it as active within one refresh cycle.
4. Click tenant and workspace operations-attention links and confirm `/admin/operations` opens with the same problem class visible on arrival.
5. Open a stale-active run in `/admin/operations/{run}` and confirm the decision zone answers whether the run is still active and what to do next.
6. Open an auto-reconciled run in `/admin/operations/{run}` and confirm the decision zone answers that the run is no longer active, that reconciliation already happened, and what follow-up is appropriate.
7. As a platform user, open `/system/ops/stuck`, `/system/ops/failures`, and `/system/ops/runs/{run}` and confirm stale/reconciled lineage stays discoverable across the system truth chain.
8. Open an operation completion notification for a reconciled stale run and confirm the linked destination visibly confirms the same problem class.
9. Time a seeded operator walkthrough across tenant dashboard, workspace overview, `/admin/operations`, `/admin/operations/{run}`, and `/system/ops/runs/{run}` and confirm each entry surface makes the run's fresh-active, likely-stale, reconciled, or terminal-follow-up state understandable within 10 seconds.
## Non-Goals For This Slice
- No schema migration.
- No new lifecycle or problem-state table.
- No new route family for tenant-specific operations monitoring.
- No notification subsystem redesign.
- No tenant-admin retry/cancel capability expansion.

View File

@ -0,0 +1,73 @@
# Phase 0 Research: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
## Decision: Reuse the existing freshness and reconciliation model as the lifecycle base
**Rationale**: The repo already has `OperationRunFreshnessState`, `OperationLifecyclePolicy`, `likelyStale()` model scope logic, and `context.reconciliation` metadata. The trust problem is not missing lifecycle truth. It is that different surfaces summarize or surface that truth differently. Reusing the current freshness/reconciliation model is narrower and keeps Spec 178 aligned with Spec 160 instead of creating a competing lifecycle layer.
**Alternatives considered**:
- Create a new persisted problem-state field on `operation_runs`: rejected because the feature is explicitly scoped away from schema change and because the current operator problem is cross-surface drift, not missing stored truth.
- Introduce a second enum family for `problem class`: rejected because the current freshness and outcome model already provides enough signal for a derived split.
## Decision: Introduce a thin derived split between `terminal follow-up` and `active stale/stuck attention`
**Rationale**: The current main mixing point is `OperationRun::dashboardNeedsFollowUp()`, which ORs terminal failures and `likelyStale()` runs into one bucket. The narrowest correction is to derive two explicit attention classes from existing outcome and freshness truth, not to create a dashboard or monitoring framework.
**Alternatives considered**:
- Keep `needs follow-up` as the only bucket and add more text: rejected because the spec requires operators to distinguish active stale from terminal problems without guesswork.
- Build a new cross-domain attention taxonomy: rejected as disproportionate for a monitoring-only hardening slice.
## Decision: Extend existing model/query and presenter seams instead of introducing a new helper framework
**Rationale**: Existing seams already own most of the relevant logic: `OperationRun` scopes own query truth, `OperationUxPresenter` owns operator-facing guidance and notification wording, `ActiveRuns` already controls polling on several surfaces, and `OperationRunLinks` already owns canonical navigation. Extending those seams keeps the change local and consistent with repo bias.
**Alternatives considered**:
- Add a new reusable classification service or taxonomy registry: rejected because the slice only needs a thin derived split and would risk introducing a semantic framework the constitution explicitly disfavors.
- Push all logic into widget-local queries: rejected because that would preserve or worsen the existing drift.
## Decision: Apply the repo's conditional polling pattern to `BulkOperationProgress`
**Rationale**: Dashboard and recent-operation widgets already poll only while active runs exist. `BulkOperationProgress` currently refreshes its run list but does not share the same disciplined poll gate. Extending the existing 10-second active-run polling pattern to that component is the narrowest way to remove stale live-progress residue.
**Alternatives considered**:
- Keep the overlay event-driven only: rejected because the spec explicitly identifies stale UI residue after canonical truth changes without a new enqueue event.
- Add aggressive always-on polling: rejected because other repo surfaces already established a conditional active-run-only approach.
## Decision: Keep `BulkOperationProgress` as an active-only surface
**Rationale**: The overlay's job is to tell the operator about currently active work. Once a run becomes terminal or reconciled, the canonical follow-up story belongs on recent operations, attention surfaces, the monitoring hub, and detail pages. Removing terminal/reconciled runs from the overlay is narrower and preserves surface roles.
**Alternatives considered**:
- Reclassify terminal or reconciled runs inside the overlay: rejected because it would turn the overlay into a second summary/attention widget with overlapping semantics.
- Leave terminal/reconciled runs visible until manual refresh: rejected because it directly violates the spec's trust objective.
## Decision: Keep `/admin/operations` as the sole canonical collection route and preserve problem class through filter state
**Rationale**: The repo already standardized canonical operations navigation on `/admin/operations` and `/admin/operations/{run}`. Dashboard, workspace, and notification entry points should carry tenant-safe problem-class filter or tab state into that route instead of opening new collection pages.
**Alternatives considered**:
- Add tenant-specific operations collection routes: rejected because the repo already treats canonical operations as workspace-context routes with tenant-safe filtering.
- Keep raw route links without filter continuity: rejected because that is the current trust-drift problem.
## Decision: Keep `/system/ops/stuck` focused on active stale candidates and surface reconciled stale lineage elsewhere in system monitoring
**Rationale**: The `Stuck` page is already a clean read-only registry of queued/running runs that crossed the lifecycle threshold. Widening it to include completed reconciled runs would blur that page's purpose. The narrower fix is to keep `Stuck` active-only while ensuring `/system/ops/runs`, `/system/ops/failures`, and system detail explicitly reveal when a failed or completed run was auto-reconciled from a stale lifecycle state.
**Alternatives considered**:
- Add completed reconciled runs directly to `/system/ops/stuck`: rejected because it would collapse active-stuck and terminal-history semantics into one list.
- Leave reconciled stale lineage visible only on admin surfaces: rejected because Spec 178 requires the system truth chain to remain consistent too.
## Decision: Promote stale/reconciled lifecycle truth through the existing canonical decision-zone seam
**Rationale**: Spec 164 already established the canonical run detail as a decision-first surface. The right fix is to strengthen that existing decision/current-state zone so it answers active vs reconciled vs terminal clearly. A new banner framework or a separate detail card would just reintroduce duplicated truth.
**Alternatives considered**:
- Add more banners above the current page: rejected because it would duplicate lifecycle emphasis rather than integrating it into the canonical decision hierarchy.
- Push the lifecycle answer into diagnostics only: rejected because the spec explicitly disallows that.
## Decision: Keep notification hardening on the existing `OperationRunCompleted` path
**Rationale**: Terminal notifications already route through `OperationUxPresenter::terminalDatabaseNotification()`. Extending that path to preserve stale/reconciled problem-class wording is the narrowest way to keep entry points aligned with canonical truth.
**Alternatives considered**:
- Introduce a new notification class for reconciled or stale-derived terminal runs: rejected because the spec explicitly avoids a notification redesign.
- Leave notifications unchanged and rely on the destination page only: rejected because entry-point semantics must not be calmer than the current truth.

View File

@ -0,0 +1,277 @@
# Feature Specification: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
**Feature Branch**: `178-ops-truth-alignment`
**Created**: 2026-04-05
**Status**: Proposed
**Input**: User description: "Spec 178 - Operations Lifecycle Alignment & Cross-Surface Truth Consistency"
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant + workspace + canonical-view + platform
- **Primary Routes**:
- `/admin` as the workspace overview surface where workspace attention and workspace recent operations appear
- `/admin/t/{tenant}` as the tenant dashboard surface where tenant attention, recent operations, and active progress affordances appear
- `/admin/operations` as the canonical monitoring hub and drill-through destination from admin-plane summaries
- `/admin/operations/{run}` as the canonical run detail surface
- `/system/ops/runs`, `/system/ops/failures`, and `/system/ops/stuck` as the platform-plane monitoring registry surfaces
- `/system/ops/runs/{run}` as the platform-plane operation detail surface
- **Data Ownership**:
- Existing `OperationRun` records remain the only canonical lifecycle source of truth for queued, running, completed, stale, and automatically reconciled runs
- Existing workspace-owned monitoring truth with optional tenant linkage remains in place; the feature does not add a second summary record, mirror lifecycle store, or notification-specific state model
- Freshness interpretation, stale or reconciled visibility, terminal follow-up grouping, and cross-surface drill-through continuity remain derived views over existing `OperationRun` truth
- No schema migration, no new persisted lifecycle state, and no enum rewrite are introduced
- **RBAC**:
- Admin-plane summary and canonical-view surfaces continue to require workspace membership, and any tenant-bound summary or run detail continues to require tenant entitlement for the referenced tenant
- Platform-plane system surfaces continue to rely on existing system operations view and manage capabilities without broadening `/system` access
- Non-members or users outside the relevant workspace or tenant scope remain `404`; in-scope users lacking a capability for a guarded follow-up affordance remain `403`
- Cross-plane navigation must remain explicit and must not leak tenant truth from admin surfaces into system surfaces or vice versa
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: `/admin/operations` may continue to prefilter to the active tenant, but dashboard, attention, recent-operations, and notification drill-throughs MUST also preserve the originating problem class so operators land on the same issue family they clicked. Operators may broaden filters only within already entitled scope.
- **Explicit entitlement checks preventing cross-tenant leakage**: Every admin-plane summary claim, pre-applied filter, run detail page, and related drill-through MUST resolve only after workspace membership and tenant entitlement checks against the referenced run. Reconciled, stale, terminal-failure, and follow-up states must not reveal another tenant's existence or activity to unauthorized users.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard operations attention | Embedded attention summary | One explicit problem-class CTA per summary bucket | forbidden | none | none | `/admin/operations` | `/admin/operations/{run}` | Active tenant context and tenant-preserving destination state | Operations / Operation | Separate terminal issues from stale active issues | Multi-bucket summary surface |
| Tenant dashboard recent operations | Diagnostic recency table | Row open to canonical operation detail | required | header link only | none | `/admin/operations` with tenant prefilter | `/admin/operations/{run}` | Active tenant context and tenant-scoped recent activity | Operations / Operation | Fresh active, likely stale, and terminal follow-up states remain distinguishable per row | none |
| Bulk operation progress | Live progress indicator | Compact item open to canonical operation detail plus collection fallback | compact item link only | collection link only | none | `/admin/operations` with tenant prefilter | `/admin/operations/{run}` | Active tenant context and active-run-only framing | Operations / Operation | Only truly active or still-problematic runs remain visible | Compact progress surface |
| Workspace operations attention | Embedded attention summary | One explicit problem-class CTA per summary bucket | forbidden | none | none | `/admin/operations` | `/admin/operations/{run}` | Workspace scope plus tenant counts where relevant | Operations / Operation | Separate terminal issues from stale active issues across the workspace | Multi-bucket summary surface |
| Workspace recent operations | Diagnostic recency table | Row open to canonical operation detail | required | header link only | none | `/admin/operations` | `/admin/operations/{run}` | Workspace scope with tenant identity per row | Operations / Operation | Recent operations do not hide stale or reconciled truth behind generic recency language | none |
| Operations hub | Read-only Registry / Report | Full-row click to canonical operation detail | required | filters, tabs, and header-level context only | none on the list | `/admin/operations` | `/admin/operations/{run}` | Workspace scope, tenant filter state, and problem-class filter state | Operations / Operation | Lifecycle truth, freshness truth, and problem class are visible before opening detail | none |
| Canonical operation detail | Detail-first operational surface | Dedicated detail page | forbidden | detail header links only | none introduced by this spec | `/admin/operations` | `/admin/operations/{run}` | Workspace context, tenant context when relevant, and run identity | Operations / Operation | Decision-zone lifecycle truth and next step are visible without opening diagnostics | none |
| System failed operations | Read-only Registry / Report | Full-row click to system operation detail | required | header CTA only | none | `/system/ops/failures` | `/system/ops/runs/{run}` | Platform scope only | Operations / Operation | Terminal-problem truth remains aligned with admin-plane canonical truth | none |
| System stuck operations | Read-only Registry / Report | Full-row click to system operation detail | required | header CTA only | none | `/system/ops/stuck` | `/system/ops/runs/{run}` | Platform scope only | Operations / Operation | Active stale or stuck truth and reconciled visibility remain operator-legible | 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 |
|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard operations attention | Tenant operator | Embedded attention summary | Do I have a terminal issue to follow up or an active run that is likely stale? | Separate counts or labels for terminal follow-up and stale active attention, with one matching destination each | Detailed failure payloads, count internals, and infrastructure evidence | problem class, urgency, tenant scope | none | Open terminal issues, open stale active issues | none |
| Tenant dashboard recent operations | Tenant operator | Diagnostic recency table | Which recent tenant operation should I inspect next? | Operation label, lifecycle truth, outcome, freshness truth, and recency | Failure internals, raw summary counts, extended diagnostics | execution status, execution outcome, freshness | none | Open operation detail, open operations list | none |
| Bulk operation progress | Tenant operator | Live progress indicator | Is this run really still active, or has its truth changed since enqueue time? | Active run identity, current visible lifecycle truth, and quick path to detail | Low-level progress internals and failure metadata | execution status, freshness, active visibility | none | Open operation detail, open operations list | none |
| Workspace operations attention | Workspace operator | Embedded attention summary | Which operation problem class needs workspace-level follow-up first? | Separate terminal issues from stale active issues, with workspace-safe destination semantics | Deep diagnostics remain on operations views | problem class, urgency, workspace spread | none | Open terminal issues, open stale active issues | none |
| Workspace recent operations | Workspace operator | Diagnostic recency table | Which operation across the workspace changed meaning recently? | Run identity, tenant, lifecycle truth, and recency | Deeper failure and reconciliation detail remain secondary | execution status, freshness, tenant scope | none | Open operation detail, open operations list | none |
| Operations hub | Workspace operator | Read-only Registry / Report | Is this run fresh active, likely stale, reconciled, or a terminal issue, and which bucket am I looking at? | Explicit problem-class framing, lifecycle truth, freshness truth, outcome, tenant or workspace scope | Queue internals, raw context, and extended traces | execution status, execution outcome, freshness, problem class | none | Open operation detail, adjust filter or tab | none |
| Canonical operation detail | Workspace operator | Detail-first operational surface | What happened, is the run still active, was it automatically reconciled, and what do I do next? | Primary decision zone with lifecycle assessment, active or not-active answer, reconciliation state, and one primary next step | Raw payloads, detailed failure arrays, and artifact-deep diagnostics | execution status, execution outcome, freshness, operator next action | none | Return to operations, open related artifact or follow-up destination | none introduced by this spec |
| System failed operations | Platform operator | Read-only Registry / Report | Which terminal operation issue needs platform investigation first? | Terminal problem class, operation identity, workspace, tenant, and recency | Deep diagnostics remain on system detail | execution outcome, terminal problem class, recency | none | Open operation detail, show all operations | none |
| System stuck operations | Platform operator | Read-only Registry / Report | Which active run crossed the stuck threshold or was recently auto-reconciled for that reason? | Stuck or stale class, operation identity, workspace, tenant, and recency | Deep diagnostics remain on system detail | freshness, lifecycle stall state, recency | none | Open operation detail, show all operations | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: No
- **New persisted entity/table/artifact?**: No
- **New abstraction?**: No
- **New enum/state/reason family?**: No
- **New cross-domain UI framework/taxonomy?**: Yes, but only as a narrow derived monitoring split between `terminal follow-up` and `active stale/stuck attention`, built from existing lifecycle truth rather than new stored state
- **Current operator problem**: Operators can currently see the same run framed as normal active progress on one surface, terminal or reconciled on another, invisible on system stuck surfaces, and mixed into a generic follow-up bucket elsewhere
- **Existing structure is insufficient because**: Existing surfaces already have valid local logic, but their aggregation, drill-through, and attention language do not consistently tell the same operator story for stale, reconciled, and terminal problem runs
- **Narrowest correct implementation**: Reuse the current `OperationRun`, status, outcome, freshness, and reconciliation model, then align summary buckets, filters, drill-throughs, and decision-zone emphasis across existing surfaces without adding persistence or a new lifecycle engine
- **Ownership cost**: The codebase takes on shared cross-surface classification rules, copy alignment, and regression coverage to keep dashboard, recent, bulk, admin monitoring, and system monitoring semantics locked together
- **Alternative intentionally rejected**: A new persisted problem-state model, an enum rewrite, a notification redesign, or a full operations architecture refactor were rejected because the present issue is truth drift between existing surfaces, not missing core domain structure
- **Release truth**: Current-release truth. The feature hardens already shipped lifecycle semantics before more triage or monitoring slices depend on them
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Recover The Same Truth From Every Entry Point (Priority: P1)
As an operator, I want dashboard, attention, recent-operations, monitoring, and system surfaces to describe the same run with the same problem class, so that I do not have to guess which screen is telling the truth.
**Why this priority**: Cross-surface truth drift is the core trust problem. If the same run reads differently across entry points, every later triage decision becomes suspect.
**Independent Test**: Can be fully tested by seeding fresh active, likely stale, reconciled-failed, and terminal problem runs, then verifying that tenant, workspace, canonical, and system surfaces classify the same run consistently and drill through into matching destinations.
**Acceptance Scenarios**:
1. **Given** a run is canonically `likely_stale`, **When** an operator sees it on tenant attention, workspace attention, recent operations, the operations hub, and canonical detail, **Then** none of those surfaces frame it as an unremarkable normal active run.
2. **Given** a run is terminal with a blocked, partial, or failed outcome, **When** an operator reaches it from dashboard or monitoring summaries, **Then** the destination confirms a terminal follow-up problem rather than an active stale issue.
3. **Given** a run was automatically reconciled after becoming stale, **When** an operator checks admin monitoring and system monitoring surfaces, **Then** the stale or reconciled history remains discoverable instead of disappearing from the truth chain.
---
### User Story 2 - Trust Live Progress Without Waiting For A New Event (Priority: P1)
As a tenant operator, I want local progress and recent-activity surfaces to stop implying that a finished or reconciled run is still active, even when no new enqueue event occurs, so that I can trust what is on screen.
**Why this priority**: Bulk progress and recent activity are the most immediate trust surfaces. If they lag behind canonical truth, operators see false liveness first.
**Independent Test**: Can be fully tested by opening active-progress and recent-operations surfaces, changing the underlying run to terminal or reconciled truth without dispatching a new enqueue event, and verifying that local surfaces update within the allowed refresh window and then stop behaving like live active surfaces.
**Acceptance Scenarios**:
1. **Given** BulkOperationProgress is open for an active run, **When** the run completes or is automatically reconciled, **Then** the surface stops presenting it as active within the next refresh cycle even if no new enqueue event fires.
2. **Given** Recent Operations is visible on a tenant or workspace surface, **When** a displayed run becomes likely stale or terminal, **Then** the row updates to the new truth instead of continuing to imply healthy progress.
3. **Given** no relevant active runs remain, **When** the surface reaches that state, **Then** live refresh stops or becomes inactive instead of polling indefinitely.
---
### User Story 3 - Decide What To Do From The Canonical Detail Surface (Priority: P2)
As an operator opening canonical run detail, I want the primary decision zone to tell me immediately whether the run is still active, likely stale, already reconciled, or terminal and what the next step is, so that I do not have to derive action from scattered diagnostics.
**Why this priority**: Canonical detail is the highest-trust surface. If it makes lifecycle attention secondary, summary surfaces cannot reliably inherit the right operator interpretation.
**Independent Test**: Can be fully tested by opening stale, reconciled, partial, failed, and healthy active runs and verifying that the decision zone makes lifecycle truth and next action visible without relying on banners or secondary panels alone.
**Acceptance Scenarios**:
1. **Given** a run is likely stale but not yet reconciled, **When** the canonical detail page loads, **Then** the primary decision zone states that the run is still non-terminal but likely unhealthy and names the next investigation step.
2. **Given** a run has already been automatically reconciled, **When** the canonical detail page loads, **Then** the primary decision zone states that the run is no longer active, that reconciliation already happened, and what follow-up is appropriate.
3. **Given** a run type has deeper artifact truth, **When** the canonical detail page loads, **Then** lifecycle truth and next action remain visible before artifact-deep diagnostics.
---
### User Story 4 - Preserve Problem-Class Continuity In System And Notification Entry Points (Priority: P3)
As a system or workspace operator, I want notifications and platform monitoring entry points to confirm the same problem class that brought me there, so that I never land on a calmer or differently framed destination than the one I clicked.
**Why this priority**: Link continuity is where trust drift becomes obvious. If the destination tells a different story, operators stop trusting the product's routing and labels.
**Independent Test**: Can be fully tested by navigating from dashboard KPIs, attention items, recent operations, and operation notifications into admin and system monitoring destinations, then verifying that the originating problem class is visible and recoverable on arrival.
**Acceptance Scenarios**:
1. **Given** a notification frames a run as needing terminal follow-up, **When** the operator opens the linked destination, **Then** the destination visibly confirms that terminal-problem framing.
2. **Given** a dashboard or workspace attention link frames a run as stale active attention, **When** the operator opens the monitoring destination, **Then** the destination visibly confirms the stale active problem class instead of a generic mixed bucket.
### Edge Cases
- A run may move from `likely_stale` to `reconciled_failed` while an operator keeps a local progress surface open; the UI must not continue showing healthy activity after reconciliation.
- A run may be removed from the active stuck list after reconciliation; the system truth chain must still expose that it was recently stale or auto-reconciled rather than making the issue disappear.
- A run may be terminal with a poor outcome and also belong to an artifact-heavy domain; the page must not bury the lifecycle answer behind artifact diagnostics.
- A tenant-scoped summary may link into the canonical operations hub while tenant context is stale or absent; the destination must preserve the correct tenant-safe problem filter or fall back to workspace-safe scope without changing the run's problem class.
- Notifications may be generated before a stale run is later reconciled; entry-point language and destinations must not stay calmer than the current run truth.
- Run types may differ in artifact richness, but none may diverge on the base question of fresh active, likely stale, reconciled, or terminal follow-up.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new write workflow, no new queued operation type, and no new persisted operations record. It hardens the truth alignment of existing operations and monitoring surfaces over existing `OperationRun`, freshness, and reconciliation semantics.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature stays deliberately narrow. It adds no new persistence, no new lifecycle table, no new orchestration layer, and no new enum family. The only new semantic split is a derived operator-facing distinction between terminal follow-up and active stale or stuck attention, built from existing status, outcome, freshness, and reconciliation truth.
**Constitution alignment (OPS-UX):** Existing `OperationRun` records remain subject to the three-surface feedback contract. Toasts remain intent-only. Active awareness remains on allowed progress and monitoring surfaces only. Terminal state transitions remain service-owned. This feature may change how active progress surfaces refresh and how summaries classify runs, but it must not add ad-hoc status mutation or a second terminal lifecycle model. Summary counts remain numeric-only and scheduled or system-run notification rules remain unchanged. Regression coverage MUST prove progress freshness, truth alignment, and reconciled visibility without reintroducing direct state mutation on render surfaces.
**Constitution alignment (RBAC-UX):** This feature spans the admin plane and the platform plane. Admin-plane tenant and workspace surfaces continue to use deny-as-not-found for non-members or non-entitled users, and canonical operation routes continue to authorize from workspace and tenant entitlement before revealing run truth. Platform-plane system monitoring continues to rely on platform capability checks. The feature adds no new mutation, no new destructive action, and no cross-plane bypass. Any in-scope destination affordance that is visible but capability-gated must remain helper-texted or disabled rather than turning into a misleading dead-end link.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. Authentication handshake exceptions remain unrelated to operations monitoring and cannot be used to justify stale or reconciled truth drift.
**Constitution alignment (BADGE-001):** Existing centralized semantics for operation status, outcome, freshness, and related attention labels remain authoritative. The feature MUST not allow dashboard widgets, recent-operation surfaces, operations hub rows, or notifications to invent page-local meanings for stale, reconciled, blocked, partial, or failed states.
**Constitution alignment (UI-FIL-001):** The feature reuses existing Filament widgets, tables, detail sections, alerts, tabs, and shared UI primitives. It should strengthen semantic emphasis through existing components and shared mappings, not through page-local markup or a new local status language.
**Constitution alignment (UI-NAMING-001):** The target objects are operations summary buckets, operation rows, run detail labels, and notification or entry-point copy. `Needs follow-up` may remain as an umbrella concept, but operator-facing copy MUST differentiate the two problem classes it currently mixes: terminal follow-up and active stale or stuck attention. Copy MUST not use a generic `blocked` or `needs follow-up` label for a mixed bucket unless the visible sub-class is also made explicit.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** Each changed surface keeps one primary inspect or drill-through model. Attention summaries use explicit problem-class destinations. Recent-operation tables keep row-click inspection. The operations hub remains a scan-first registry with explicit problem-class filtering. The canonical detail page remains the highest-trust detail surface. System failed and system stuck lists remain row-click-only registry surfaces. No new destructive action is introduced, and no exception to the action-surface contract is required.
**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first. Summary surfaces answer whether the operator is dealing with terminal follow-up or active stale attention. The operations hub answers which bucket the operator is in and what it means. Canonical detail answers what happened, whether the run is still active, and what to do next before showing diagnostics. System surfaces answer which platform-visible failure or stuck class is being surfaced without requiring the operator to infer it from raw context.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from canonical run truth to UI remains preferred. The feature may add a thin derived problem-class split, but it must not create redundant truth across persisted records, presenters, summaries, notifications, and system surfaces. Tests MUST focus on operator-visible consequences: whether the same run tells the same story across surfaces and whether drill-through preserves that story.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. No new View actions, no empty action groups, and no list-level destructive controls are introduced. Changed dashboard and monitoring surfaces remain inspection or drill-through surfaces only. UI-FIL-001 remains satisfied with no exemption.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The canonical run detail page keeps one primary decision zone and must elevate stale or reconciled lifecycle truth inside that decision zone rather than only in side banners or lower sections. Summary surfaces keep operator priority order: problem class first, recency and diagnostics second. Existing tables continue to support search, sort, and filtering on core lifecycle dimensions.
### Functional Requirements
- **FR-178-001**: The system MUST treat canonical `OperationRun` lifecycle and freshness truth as authoritative for every summary, list, detail, and notification surface covered by this feature.
- **FR-178-002**: The same run MUST NOT appear as fresh normal activity on one covered surface and as likely stale, reconciled, or terminal problem truth on another covered surface at the same time.
- **FR-178-003**: Covered admin and system monitoring surfaces MUST use one shared derived lifecycle interpretation that distinguishes at least `fresh_active`, `likely_stale`, `reconciled_failed`, and `terminal_normal` without introducing a new persisted state model.
- **FR-178-004**: Reconciliation behavior and system stuck monitoring MUST remain semantically aligned so stale runs do not disappear from operator truth once they are auto-reconciled.
- **FR-178-005**: Automatically reconciled stale runs MUST remain semantically discoverable for operators on admin monitoring or system monitoring surfaces within one navigation step.
- **FR-178-006**: Bulk operation progress surfaces MUST refresh while relevant active runs exist and MUST stop presenting a run as active once canonical truth becomes terminal or reconciled.
- **FR-178-007**: Bulk operation progress surfaces MUST remove or reclassify terminal or reconciled runs within one refresh cycle even when no new enqueue event occurs.
- **FR-178-008**: Recent Operations surfaces on tenant and workspace pages MUST distinguish fresh active runs, likely stale active runs, and terminal follow-up runs rather than flattening them into generic recency.
- **FR-178-009**: Tenant and workspace attention surfaces MUST separate terminal follow-up from active stale or stuck attention instead of mixing them into one undifferentiated bucket.
- **FR-178-010**: The operations hub MUST expose an explicit monitoring view, filter, or tab for active but likely stale runs and an explicit view, filter, or tab for terminal follow-up runs.
- **FR-178-011**: Dashboard, attention, KPI, and recent-operation drill-throughs into the operations hub MUST preserve the originating problem class in visible destination framing.
- **FR-178-012**: The canonical run detail page MUST present stale, reconciled, and terminal-problem lifecycle truth inside the primary decision zone rather than only in secondary banners, side panels, or lower diagnostic sections.
- **FR-178-013**: For likely stale and reconciled runs, the primary decision zone MUST answer whether the run is still active, whether automatic reconciliation already happened, and what the primary next step is.
- **FR-178-014**: Local summary and progress surfaces MUST reuse centralized status, outcome, freshness, and problem-class semantics rather than page-local mappings.
- **FR-178-015**: Notification and in-app entry-point language MUST NOT frame a run more calmly than its current lifecycle or freshness truth.
- **FR-178-016**: Cross-links from dashboard KPIs, attention surfaces, recent operations, and notifications MUST land on destination surfaces that visibly confirm the same problem class that initiated the navigation.
- **FR-178-017**: The feature MUST use the existing `OperationRun`, status, outcome, freshness, and reconciliation model without introducing a schema migration, a new persisted lifecycle artifact, or an enum rewrite.
- **FR-178-018**: Run-type differences MAY preserve deeper artifact truth, but they MUST NOT change the base lifecycle answers of fresh active, likely stale, reconciled, or terminal follow-up.
- **FR-178-019**: Regression coverage MUST prove that the same seeded runs are classified consistently across tenant dashboard, workspace overview, operations hub, canonical run detail, and system failed or stuck surfaces.
- **FR-178-020**: Regression coverage MUST prove bulk-progress freshness, reconciliation visibility, drill-through continuity, and decision-zone emphasis for stale or reconciled runs.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard operations attention | `/admin/t/{tenant}` dashboard | none | Explicit problem-class CTA per bucket | none | none | Existing healthy fallback remains read-only reassurance only when no operations issue exists | n/a | n/a | no new audit behavior | Summary surface only; must not render mixed problem buckets |
| Tenant dashboard recent operations | `/admin/t/{tenant}` dashboard | `Open operations` | Row click to canonical operation detail | none | none | Existing diagnostic empty state remains non-primary | n/a | n/a | no new audit behavior | Recency surface; no destructive actions |
| Workspace operations attention | `/admin` workspace overview | none | Explicit problem-class CTA per bucket | none | none | Existing healthy fallback remains read-only reassurance only when no operations issue exists | n/a | n/a | no new audit behavior | Summary surface only; must not render mixed problem buckets |
| Workspace recent operations | `/admin` workspace overview | `Open operations` | Row click to canonical operation detail | none | none | Existing diagnostic empty state remains non-primary | n/a | n/a | no new audit behavior | Recency surface; no destructive actions |
| Operations hub | `/admin/operations` | Filter or tab controls only; no new destructive actions | Full-row click to canonical operation detail | none | none | Existing empty state remains explanatory and filter-aware | n/a | n/a | no new audit behavior | Scan-first registry surface; problem-class filters must align with summary entry points |
| Canonical operation detail | `/admin/operations/{run}` | `Back to operations` plus existing related navigation only | n/a | n/a | n/a | n/a | Existing related navigation only; no new destructive action introduced by this spec | n/a | no new audit behavior | Decision-zone truth is the hardening target |
| System failed operations | `/system/ops/failures` | `Show all operations` | Full-row click to system operation detail | none | none | `Show all operations` | n/a | n/a | no new audit behavior | Must confirm terminal-problem semantics, not generic follow-up |
| System stuck operations | `/system/ops/stuck` | `Show all operations` | Full-row click to system operation detail | none | none | `Show all operations` | n/a | n/a | no new audit behavior | Must preserve stale or reconciled visibility for platform operators |
### Key Entities *(include if feature involves data)*
- **Operation Run**: The canonical operational record whose status, outcome, freshness, and reconciliation context define the authoritative lifecycle truth.
- **Freshness State**: The derived lifecycle interpretation that distinguishes fresh active work, likely stale work, reconciled failure, and normal terminal completion without adding new persisted state.
- **Problem Class**: The operator-facing split between terminal follow-up and active stale or stuck attention, derived from existing lifecycle truth and used to align summary surfaces and drill-throughs.
- **Drill-through Contract**: The promise that a summary count, notification, or attention label can be visibly rediscovered on the destination surface it opens.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-178-001**: In covered regression scenarios, 100% of runs seeded as `likely_stale` are shown as stale or otherwise problematic on every covered summary and monitoring surface, and 0 are shown as unremarkable fresh activity.
- **SC-178-002**: In covered regression scenarios, 100% of automatically reconciled stale runs remain semantically recoverable for operators through admin monitoring or system monitoring within one navigation step.
- **SC-178-003**: In covered freshness regression scenarios, local progress surfaces stop showing terminal or reconciled runs as active within one refresh cycle and without requiring a new enqueue event.
- **SC-178-004**: In covered navigation regression scenarios, 100% of dashboard, attention, recent-operation, and notification drill-throughs land on destinations whose visible framing matches the originating problem class.
- **SC-178-005**: In operator review on seeded scenarios, an operator can determine within 10 seconds whether the run is fresh active, likely stale, reconciled, or terminal follow-up from every covered entry surface.
- **SC-178-006**: The feature ships without a schema migration, a new persisted lifecycle artifact, or a new status or outcome family.
## Assumptions
- Existing lifecycle freshness and reconciliation semantics from the operation lifecycle guarantees work remain the authoritative base truth for this hardening slice.
- Existing run-detail decision-zone structure remains the correct place to elevate stale and reconciled lifecycle truth.
- Existing tenant and workspace dashboard truth alignment work remains the baseline grammar for admin-plane summary surfaces.
- Existing system operations surface alignment remains the baseline interaction model for `/system/ops/failures` and `/system/ops/stuck`.
## Non-Goals
- Introducing tenant-admin retry or cancel capabilities
- Rebuilding the operations domain, run schema, or lifecycle engine
- Adding a new persisted problem-state model, enum rewrite, or schema migration
- Redesigning all notification behavior across the product
- Performing deep non-governance result-quality analysis for every run type
- Replacing run-type-specific artifact truth with a uniform artifact model
## Dependencies
- Existing operations auto-refresh behavior and active-run polling patterns
- Existing operation lifecycle guarantees, freshness thresholds, and reconciliation behavior
- Existing canonical run detail hierarchy and decision-zone structure
- Existing tenant dashboard and workspace overview truth-alignment semantics
- Existing system operations surface alignment for row-click-only platform monitoring pages
## Risks
- If stale thresholds are too aggressive, legitimate long-running work could be surfaced as stale too early.
- If summary and monitoring surfaces share labels but not the same underlying filter meaning, operators will continue to mistrust drill-throughs.
- If reconciled stale visibility is over-corrected without hierarchy, system surfaces could become noisy instead of trustworthy.
- If local progress polling is too eager, the product could gain freshness at the cost of unnecessary load.
## Definition of Done
Spec 178 is complete when:
- BulkOperationProgress no longer leaves trust-damaging stale residue that keeps terminal or reconciled runs looking active.
- stale or stuck semantics are consistent between lifecycle reconciliation, tenant and workspace summaries, the operations hub, canonical run detail, and system stuck or failure surfaces.
- tenant and workspace summary surfaces visibly separate terminal problem runs from active stale or stuck runs.
- the operations hub no longer distorts dashboard semantics through mixed or misleading tabs, filters, or bucket names.
- the canonical run detail page prioritizes stale or reconciled lifecycle truth inside the primary decision zone.
- cross-surface links preserve the same operator-visible problem class from origin to destination.
- focused regression coverage proves truth alignment, stale visibility, drill-through continuity, and progress freshness.
## Summary
This feature is a late-foundation hardening slice for the operations domain. The underlying lifecycle model is already strong: `OperationRun` is canonical, status and outcome are separated, stale reconciliation exists, system stuck surfaces exist, and canonical run detail already owns the deepest operational truth. The remaining problem is not missing architecture; it is trust drift between surfaces that summarize or relabel that truth.
Spec 178 closes that gap by making every covered surface tell the same story about whether a run is still active, likely stale, already reconciled, or terminal and in need of follow-up. It keeps the model narrow by reusing existing lifecycle and freshness truth, then aligning summaries, live progress, drill-throughs, and decision-zone emphasis so operators do not have to reconcile conflicting screens by hand.

View File

@ -0,0 +1,256 @@
# Tasks: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
**Input**: Design documents from `/specs/178-ops-truth-alignment/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/operations-truth-alignment.openapi.yaml`
**Tests**: Tests are REQUIRED for this feature. Use Pest coverage in `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `tests/Feature/Monitoring/MonitoringOperationsTest.php`, `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, `tests/Feature/Monitoring/OperationsDbOnlyTest.php`, `tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `tests/Feature/Notifications/OperationRunNotificationTest.php`, `tests/Feature/System/Spec114/CanonicalRunDetailTest.php`, `tests/Feature/System/Spec114/OpsFailuresViewTest.php`, `tests/Feature/System/Spec114/OpsStuckViewTest.php`, `tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `tests/Feature/Guards/ActionSurfaceContractTest.php`, and `tests/Feature/RunAuthorizationTenantIsolationTest.php`.
**Operations**: This feature does not create a new `OperationRun` type or change lifecycle ownership. Tasks must keep `OperationRun` as the only canonical truth, keep active awareness on existing progress and monitoring surfaces, preserve exactly-once terminal notification behavior via `app/Notifications/OperationRunCompleted.php`, and avoid any new queued/running database notifications.
**RBAC**: Existing admin-plane and system-plane authorization remains authoritative. Tasks must preserve tenant-safe `/admin/operations` filter continuity, maintain `404` for non-member/non-entitled scope access, maintain `403` for in-scope capability failures where applicable, and avoid any cross-plane leakage.
**Operator Surfaces**: Tenant dashboard attention and recency, workspace attention and recency, `BulkOperationProgress`, `/admin/operations`, `/admin/operations/{run}`, `/system/ops/runs`, `/system/ops/failures`, `/system/ops/stuck`, and `/system/ops/runs/{run}` must stay operator-first and expose lifecycle/problem-class truth by default.
**Filament UI Action Surfaces**: This feature changes summary, registry, and detail surfaces only. No new destructive actions, no empty action groups, and no redundant inspect affordances are introduced. Row-click/detail ownership and CTA semantics must remain aligned with the existing Action Surface Contract.
**Filament UI UX-001**: No new create/edit/view CRUD pages are introduced. Existing widgets, monitoring pages, and system pages keep their current layout while stale/reconciled emphasis is hardened inside current summary and decision-zone structures.
**Badges**: Existing centralized status, outcome, and freshness semantics remain authoritative. Do not introduce page-local badge mappings.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment.
## Phase 1: Setup
**Purpose**: Lock the implementation targets to the generated design artifacts and current runtime seams before editing behavior.
- [X] T001 Reconfirm the aligned surface contract and verification pack in `specs/178-ops-truth-alignment/contracts/operations-truth-alignment.openapi.yaml` and `specs/178-ops-truth-alignment/quickstart.md` before touching runtime files.
- [X] T002 Inspect the current lifecycle-truth touchpoints in `app/Models/OperationRun.php`, `app/Support/OpsUx/OperationUxPresenter.php`, `app/Support/OpsUx/ActiveRuns.php`, `app/Support/OperationRunLinks.php`, `app/Livewire/BulkOperationProgress.php`, `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, and `app/Filament/System/Pages/Ops/ViewRun.php` so every story maps to an existing seam.
---
## Phase 2: Foundational
**Purpose**: Establish the shared truth-derivation, filter-state, and guard baselines that every story depends on.
**⚠️ CRITICAL**: No user story work should be considered complete until these shared seams are in place.
- [X] T003 Update the shared cross-surface regression baseline in `tests/Feature/Monitoring/MonitoringOperationsTest.php` and `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` so stale-active versus terminal-follow-up continuity can be asserted before story-specific rendering changes.
- [X] T004 [P] Update guard and authorization baseline coverage in `tests/Feature/Guards/ActionSurfaceContractTest.php` and `tests/Feature/RunAuthorizationTenantIsolationTest.php` so tenant-safe filter continuity, inspect semantics, and `404` versus `403` expectations are locked before surface edits.
- [X] T005 [P] Implement the shared derived problem-class and stale-lineage helpers in `app/Models/OperationRun.php`, `app/Support/OpsUx/OperationUxPresenter.php`, and `app/Support/OpsUx/ActiveRuns.php`.
- [X] T006 [P] Implement canonical problem-class drill-through and filter-state support in `app/Support/OperationRunLinks.php` and `app/Filament/Pages/Monitoring/Operations.php`.
**Checkpoint**: The shared truth contract, canonical drill-through state, and guard expectations are ready for story work.
---
## Phase 3: User Story 1 - Recover The Same Truth From Every Entry Point (Priority: P1) 🎯 MVP
**Goal**: Make tenant, workspace, canonical admin, and system monitoring surfaces classify the same run with the same problem class and drill-through framing.
**Independent Test**: Seed fresh active, likely stale, reconciled-failed, and terminal-problem runs, then verify tenant/workspace summaries, `/admin/operations`, `/admin/operations/{run}`, `/system/ops/failures`, and `/system/ops/stuck` all surface the same lifecycle story and matching destinations.
### Tests for User Story 1
- [X] T007 [P] [US1] Add tenant/workspace summary bucket and row-truth assertions in `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, and `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`.
- [X] T008 [P] [US1] Add admin/system list truth-alignment assertions for stale lineage and problem-class filters in `tests/Feature/Monitoring/OperationsDbOnlyTest.php`, `tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `tests/Feature/System/Spec114/OpsFailuresViewTest.php`, and `tests/Feature/System/Spec114/OpsStuckViewTest.php`.
### Implementation for User Story 1
- [X] T009 [P] [US1] Split tenant terminal-follow-up versus active-stale attention buckets in `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Widgets/Dashboard/NeedsAttention.php`.
- [X] T010 [P] [US1] Render problem-class-aware recent-operation rows on the tenant dashboard in `app/Filament/Widgets/Dashboard/RecentOperations.php`.
- [X] T011 [P] [US1] Mirror the same bucket and row truth on workspace surfaces in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, and `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`.
- [X] T012 [US1] Align canonical admin and system monitoring list surfaces around the shared problem-class contract in `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, and `app/Filament/System/Pages/Ops/Stuck.php`.
- [X] T013 [US1] Run focused US1 verification from `specs/178-ops-truth-alignment/quickstart.md` against the Filament, monitoring, and system test files updated in T007 and T008.
**Checkpoint**: Cross-surface list and summary entry points now tell the same stale, reconciled, and terminal truth.
---
## Phase 4: User Story 2 - Trust Live Progress Without Waiting For A New Event (Priority: P1)
**Goal**: Make local progress and recency surfaces converge on canonical truth within one polling cycle even when no new enqueue event is emitted.
**Independent Test**: Keep `BulkOperationProgress` open while a run changes from active to terminal or reconciled without a new enqueue event, then verify the overlay stops treating it as active and recent-operation rows refresh to the new truth.
### Tests for User Story 2
- [X] T014 [P] [US2] Add active-only polling and no-new-enqueue convergence assertions in `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`.
- [X] T015 [P] [US2] Add recent-operations freshness-update assertions for tenant/workspace surfaces in `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php` and `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`.
### Implementation for User Story 2
- [X] T016 [US2] Implement conditional polling, active-only snapshots, and terminal/reconciled removal in `app/Livewire/BulkOperationProgress.php` and `resources/views/livewire/bulk-operation-progress.blade.php`.
- [X] T017 [US2] Tighten active-run polling gates and visibility decisions for local progress in `app/Support/OpsUx/ActiveRuns.php` and `app/Models/OperationRun.php`.
- [X] T018 [US2] Re-render tenant/workspace recent-operation freshness after canonical truth changes in `app/Filament/Widgets/Dashboard/RecentOperations.php`, `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, and `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`.
- [X] T019 [US2] Run focused US2 verification from `specs/178-ops-truth-alignment/quickstart.md` against `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, and `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`.
**Checkpoint**: Local progress and recency surfaces no longer show false activity after canonical truth changes.
---
## Phase 5: User Story 3 - Decide What To Do From The Canonical Detail Surface (Priority: P2)
**Goal**: Make the canonical decision zone explain current lifecycle truth, reconciliation state, and primary next step before artifact-deep diagnostics.
**Independent Test**: Open stale, reconciled, partial, failed, and healthy active runs and verify the primary decision zone answers whether the run is still active, whether reconciliation already happened, and what to do next without relying on secondary diagnostics.
### Tests for User Story 3
- [X] T020 [P] [US3] Add canonical admin detail decision-zone assertions for stale, reconciled, terminal, and artifact-rich runs in `tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php` and `tests/Feature/Monitoring/MonitoringOperationsTest.php`.
- [X] T021 [P] [US3] Add system detail decision-zone priority assertions, including artifact-rich run behavior and stale-lineage triage context, in `tests/Feature/System/Spec114/CanonicalRunDetailTest.php` and `tests/Feature/System/Spec114/OpsTriageActionsTest.php`.
### Implementation for User Story 3
- [X] T022 [US3] Elevate stale/reconciled lifecycle truth and next-step guidance in the canonical admin detail composition in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and `app/Support/OpsUx/OperationUxPresenter.php`.
- [X] T023 [US3] Surface the same decision-zone contract on the system detail page in `app/Filament/System/Pages/Ops/ViewRun.php` and `resources/views/filament/system/pages/ops/view-run.blade.php`.
- [X] T024 [US3] Align detail copy so lifecycle truth stays above artifact-deep diagnostics in `app/Support/OpsUx/OperationUxPresenter.php`, `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and `app/Filament/System/Pages/Ops/ViewRun.php`.
- [X] T025 [US3] Run focused US3 verification from `specs/178-ops-truth-alignment/quickstart.md` against `tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, `tests/Feature/Monitoring/MonitoringOperationsTest.php`, `tests/Feature/System/Spec114/CanonicalRunDetailTest.php`, and `tests/Feature/System/Spec114/OpsTriageActionsTest.php`.
**Checkpoint**: Canonical detail surfaces answer the operator's first lifecycle question before any deep diagnostics.
---
## Phase 6: User Story 4 - Preserve Problem-Class Continuity In System And Notification Entry Points (Priority: P3)
**Goal**: Keep notifications and platform entry points visibly aligned with the same problem class that initiated navigation.
**Independent Test**: Enter the flow from dashboard/workspace attention, recent operations, and notifications, then verify the destination preserves the same problem class, stale lineage, and authorization-safe context on arrival.
### Tests for User Story 4
- [X] T026 [P] [US4] Add notification problem-class wording and linked-destination continuity assertions in `tests/Feature/Notifications/OperationRunNotificationTest.php` and `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`.
- [X] T027 [P] [US4] Add reconciled stale-lineage visibility and plane-safe navigation assertions in `tests/Feature/System/Spec114/OpsFailuresViewTest.php`, `tests/Feature/System/Spec114/OpsStuckViewTest.php`, and `tests/Feature/RunAuthorizationTenantIsolationTest.php`.
### Implementation for User Story 4
- [X] T028 [US4] Preserve problem-class wording and stale-lineage cues in `app/Notifications/OperationRunCompleted.php` and `app/Support/OpsUx/OperationUxPresenter.php`.
- [X] T029 [US4] Preserve tenant-safe problem-class landing state from dashboard/workspace/notification entry points in `app/Support/OperationRunLinks.php` and `app/Filament/Pages/Monitoring/Operations.php`.
- [X] T030 [US4] Make reconciled stale lineage visible across platform entry surfaces in `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, and `app/Filament/System/Pages/Ops/ViewRun.php`.
- [X] T031 [US4] Run focused US4 verification from `specs/178-ops-truth-alignment/quickstart.md` against `tests/Feature/Notifications/OperationRunNotificationTest.php`, `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/System/Spec114/OpsFailuresViewTest.php`, `tests/Feature/System/Spec114/OpsStuckViewTest.php`, and `tests/Feature/RunAuthorizationTenantIsolationTest.php`.
**Checkpoint**: Notifications and system/admin entry points preserve the same problem-class story through to their destinations.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Finish shared copy alignment, formatting, and final verification across all stories.
- [X] T032 [P] Align remaining operator-facing truth labels and helper copy in `app/Filament/Widgets/Dashboard/DashboardKpis.php`, `app/Filament/Widgets/Dashboard/NeedsAttention.php`, `app/Filament/Widgets/Dashboard/RecentOperations.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`, `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `app/Filament/System/Pages/Ops/ViewRun.php`, `app/Notifications/OperationRunCompleted.php`, and `resources/views/filament/system/pages/ops/view-run.blade.php`.
- [X] T033 Run formatting with `vendor/bin/sail bin pint --dirty --format agent` for the runtime and test files touched by Spec 178 using `specs/178-ops-truth-alignment/quickstart.md`.
- [X] T034 Run the full focused verification pack in `specs/178-ops-truth-alignment/quickstart.md` against the Filament, OpsUx, Monitoring, System, Notification, Guard, and authorization tests touched by this feature.
- [X] T035 Validate the final behavior against `specs/178-ops-truth-alignment/contracts/operations-truth-alignment.openapi.yaml` and `specs/178-ops-truth-alignment/quickstart.md` before handoff.
- [X] T036 Run the timed 10-second operator-comprehension smoke check from `specs/178-ops-truth-alignment/spec.md` and `specs/178-ops-truth-alignment/quickstart.md` across tenant, workspace, admin, and system entry surfaces before handoff.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies.
- **Foundational (Phase 2)**: Depends on Setup and establishes the shared truth/guard baseline.
- **User Story 1 (Phase 3)**: Depends on Foundational.
- **User Story 2 (Phase 4)**: Depends on Foundational.
- **User Story 3 (Phase 5)**: Depends on Foundational.
- **User Story 4 (Phase 6)**: Depends on Foundational and should follow User Story 1 so destination problem-class framing already exists.
- **Polish (Phase 7)**: Depends on the desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: Can start immediately after Phase 2 and is the recommended MVP.
- **US2 (P1)**: Can start after Phase 2 and remains independently testable, though it reuses the same shared problem-class helpers as US1.
- **US3 (P2)**: Can start after Phase 2 because it focuses on detail-surface emphasis rather than summary routing.
- **US4 (P3)**: Should start after US1 and US3 stabilize the visible destination framing used by notifications and platform entry points.
### Within Each User Story
- Add or update story-specific tests first and make them fail for the intended behavior.
- Apply runtime source changes next.
- Run the smallest focused verification pack before moving to another story.
---
## Parallel Opportunities
- `T004`, `T005`, and `T006` can run in parallel after `T003` establishes the shared monitoring baseline.
- `T007` and `T008` can run in parallel for User Story 1.
- `T009`, `T010`, and `T011` can run in parallel for User Story 1 once the foundational helpers land.
- `T014` and `T015` can run in parallel for User Story 2.
- `T016` and `T017` can run in parallel for User Story 2 because they touch the Livewire surface versus shared polling helpers.
- `T020` and `T021` can run in parallel for User Story 3.
- `T022` and `T023` can run in parallel for User Story 3.
- `T026` and `T027` can run in parallel for User Story 4.
- `T028`, `T029`, and `T030` can run in parallel for User Story 4 once the tests are in place.
---
## Parallel Example: User Story 1
```bash
Task: "T007 tests/Feature/Filament/DashboardKpisWidgetTest.php, tests/Feature/Filament/NeedsAttentionWidgetTest.php, tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php, tests/Feature/Filament/WorkspaceOverviewOperationsTest.php"
Task: "T008 tests/Feature/Monitoring/OperationsDbOnlyTest.php, tests/Feature/Monitoring/OperationsTenantScopeTest.php, tests/Feature/System/Spec114/OpsFailuresViewTest.php, tests/Feature/System/Spec114/OpsStuckViewTest.php"
Task: "T009 app/Filament/Widgets/Dashboard/DashboardKpis.php and app/Filament/Widgets/Dashboard/NeedsAttention.php"
```
---
## Parallel Example: User Story 2
```bash
Task: "T014 tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php"
Task: "T015 tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php and tests/Feature/Filament/WorkspaceOverviewOperationsTest.php"
Task: "T016 app/Livewire/BulkOperationProgress.php and resources/views/livewire/bulk-operation-progress.blade.php"
```
---
## Parallel Example: User Story 3
```bash
Task: "T020 tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php and tests/Feature/Monitoring/MonitoringOperationsTest.php"
Task: "T021 tests/Feature/System/Spec114/CanonicalRunDetailTest.php and tests/Feature/System/Spec114/OpsTriageActionsTest.php"
Task: "T023 app/Filament/System/Pages/Ops/ViewRun.php and resources/views/filament/system/pages/ops/view-run.blade.php"
```
---
## Parallel Example: User Story 4
```bash
Task: "T026 tests/Feature/Notifications/OperationRunNotificationTest.php and tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php"
Task: "T027 tests/Feature/System/Spec114/OpsFailuresViewTest.php, tests/Feature/System/Spec114/OpsStuckViewTest.php, and tests/Feature/RunAuthorizationTenantIsolationTest.php"
Task: "T028 app/Notifications/OperationRunCompleted.php and app/Support/OpsUx/OperationUxPresenter.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 cross-surface truth-alignment slice with the focused US1 verification run before touching local progress or detail emphasis.
### Incremental Delivery
1. Finish Setup + Foundational to lock shared truth derivation, canonical filter state, and guard expectations.
2. Deliver User Story 1 for summary/list truth alignment.
3. Deliver User Story 2 for local progress freshness and active-only convergence.
4. Deliver User Story 3 for canonical decision-zone hardening.
5. Deliver User Story 4 for notification/system entry-point continuity.
6. Finish with Phase 7 polish, formatting, full focused verification, and the timed operator-comprehension smoke check.
### Parallel Team Strategy
1. One engineer can take the Foundational shared helper work while another updates the shared guard/monitoring baseline tests.
2. After Phase 2:
- Engineer A can take US1 summary and monitoring alignment.
- Engineer B can take US2 local progress and recency freshness.
- Engineer C can prepare US3 decision-zone tests.
3. US4 should land after destination framing is stable, then Phase 7 closes the feature with shared verification.
---
## Notes
- `[P]` tasks touch different files or are safe concurrent work once their dependencies are met.
- `[US1]`, `[US2]`, `[US3]`, and `[US4]` map directly to the user stories in `spec.md`.
- The suggested MVP scope is Phase 1 through Phase 3.
- This plan intentionally avoids schema changes, provider-registration changes, new assets, new destructive actions, and any second lifecycle model.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Spec 179 - Provider Readiness Source-of-Truth Cleanup
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-04
**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 on 2026-04-04.
- Constitution-required route and action-surface references are intentionally confined to the surface contract and action matrix.
- No clarification questions were required because the requested truth rules, scope boundaries, and follow-up exclusions were explicit.

View File

@ -0,0 +1,390 @@
openapi: 3.1.0
info:
title: Provider Truth Cleanup Internal Surface Contract
version: 0.1.0
summary: Internal planning contract for tenant and provider status-truth cleanup
description: |
This contract is an internal planning artifact for Spec 179. The affected
routes continue to render HTML. The schemas below describe the structured
truth that must be derivable before rendering so tenant and provider
surfaces stop elevating legacy status projections over lifecycle, consent,
and verification.
servers:
- url: /internal
x-surface-consumers:
- surface: tenant.list
summarySource:
- tenant.lifecycle
- optional_bounded_provider_signal
guardScope:
- app/Filament/Resources/TenantResource.php
expectedContract:
- lifecycle_is_primary_tenant_truth
- legacy_app_status_is_not_default_visible
- provider_signal_may_be_omitted_if_no_truthful_bounded_summary_exists
- surface: tenant.detail.provider_summary
summarySource:
- tenant.lifecycle
- tenant.provider_connection_state_helper
- provider_connection.consent_status
- provider_connection.verification_status
- latest_provider_connection_check_run
guardScope:
- app/Filament/Resources/TenantResource.php
- resources/views/filament/infolists/entries/provider-connection-state.blade.php
- app/Filament/Widgets/Tenant/TenantVerificationReport.php
expectedContract:
- lifecycle_is_separate_from_provider_truth
- provider_summary_leads_with_consent_and_verification
- legacy_status_and_health_are_diagnostic_only
- missing_default_connection_never_reads_as_ready
- surface: provider_connections.list
summarySource:
- provider_connection.consent_status
- provider_connection.verification_status
- provider_connection.connection_type
- provider_connection.is_default
- provider_connection.legacy_diagnostics
guardScope:
- app/Filament/Resources/ProviderConnectionResource.php
- app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php
expectedContract:
- consent_and_verification_are_default_visible_axes
- legacy_status_and_health_are_secondary
- core_filters_follow_leading_truth
- surface: provider_connections.detail
summarySource:
- provider_connection.consent_status
- provider_connection.verification_status
- provider_connection.legacy_diagnostics
guardScope:
- app/Filament/Resources/ProviderConnectionResource.php
- app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php
- app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php
expectedContract:
- current_state_and_diagnostics_are_visually_separate
- configured_or_consented_is_not_equated_with_verified
- legacy_diagnostics_do_not_dominate
paths:
/admin/tenants:
get:
summary: Render the tenant list with lifecycle-led truth and no legacy app-status prominence
operationId: viewTenantListTruthCleanup
responses:
'200':
description: Tenant list rendered with lifecycle as the primary tenant truth and no default-visible app-status truth
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.tenant-list-truth+json:
schema:
$ref: '#/components/schemas/TenantListTruthBundle'
'404':
description: Workspace or tenant scope is outside entitlement
/admin/tenants/{tenant}:
get:
summary: Render tenant detail with lifecycle and provider truth separated
operationId: viewTenantTruthDetail
parameters:
- name: tenant
in: path
required: true
schema:
type: string
responses:
'200':
description: Tenant detail rendered with lifecycle separate from provider consent and provider verification
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.tenant-detail-truth+json:
schema:
$ref: '#/components/schemas/TenantDetailTruthModel'
'404':
description: Tenant is outside entitlement scope
/admin/provider-connections:
get:
summary: Render the canonical provider-connections list with consent and verification as primary axes
operationId: viewProviderConnectionsTruthList
responses:
'200':
description: Provider-connection list rendered with current-state truth leading and legacy diagnostics secondary
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.provider-connections-list-truth+json:
schema:
$ref: '#/components/schemas/ProviderConnectionListTruthBundle'
'302':
description: Workspace members without an active tenant filter or equivalent context may be redirected according to existing canonical-admin rules
'404':
description: Workspace or tenant scope is outside entitlement
/admin/provider-connections/{record}:
get:
summary: Render provider connection detail with current state and diagnostics separated
operationId: viewProviderConnectionTruthDetail
parameters:
- name: record
in: path
required: true
schema:
type:
- integer
- string
responses:
'200':
description: Provider-connection detail rendered with consent and verification as the leading state contract
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.provider-connection-detail-truth+json:
schema:
$ref: '#/components/schemas/ProviderConnectionDetailTruthModel'
'403':
description: Actor is in scope but lacks the capability required for a protected action on the page
'404':
description: Provider connection is outside entitlement scope
/admin/provider-connections/{record}/edit:
get:
summary: Render provider connection edit with current state context before mutations
operationId: editProviderConnectionTruthDetail
parameters:
- name: record
in: path
required: true
schema:
type:
- integer
- string
responses:
'200':
description: Provider-connection edit page rendered with current consent and verification context and separate diagnostics
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.provider-connection-edit-truth+json:
schema:
$ref: '#/components/schemas/ProviderConnectionDetailTruthModel'
'403':
description: Actor is in scope but lacks manage capability
'404':
description: Provider connection is outside entitlement scope
components:
schemas:
VisibilityMode:
type: string
enum:
- hidden
- diagnostic
- primary
ProviderSignal:
type: object
required:
- mode
- explanation
properties:
mode:
type: string
enum:
- omitted
- missing_default_connection
- consent_verification_summary
consentStatus:
type:
- string
- 'null'
verificationStatus:
type:
- string
- 'null'
explanation:
type: string
TenantListRow:
type: object
required:
- tenantId
- tenantLabel
- lifecycle
- legacyAppStatusVisibility
- primaryInspectUrl
properties:
tenantId:
type: integer
tenantLabel:
type: string
lifecycle:
type: string
providerSignal:
oneOf:
- $ref: '#/components/schemas/ProviderSignal'
- type: 'null'
legacyAppStatusVisibility:
$ref: '#/components/schemas/VisibilityMode'
primaryInspectUrl:
type: string
TenantListTruthBundle:
type: object
required:
- rows
properties:
rows:
type: array
items:
$ref: '#/components/schemas/TenantListRow'
TenantProviderSummary:
type: object
required:
- connectionPresence
- ctaUrl
- legacyStatusVisibility
- legacyHealthVisibility
properties:
connectionPresence:
type: string
enum:
- missing
- configured
- default_configured
ctaUrl:
type: string
displayName:
type:
- string
- 'null'
provider:
type:
- string
- 'null'
consentStatus:
type:
- string
- 'null'
verificationStatus:
type:
- string
- 'null'
lastCheckedAt:
type:
- string
- 'null'
lastErrorReasonCode:
type:
- string
- 'null'
legacyStatusVisibility:
$ref: '#/components/schemas/VisibilityMode'
legacyHealthVisibility:
$ref: '#/components/schemas/VisibilityMode'
TenantDetailTruthModel:
type: object
required:
- tenantId
- lifecycle
- providerSummary
properties:
tenantId:
type: integer
lifecycle:
type: string
providerSummary:
$ref: '#/components/schemas/TenantProviderSummary'
verificationReportSurface:
type: string
description: Existing tenant verification widget remains the deep-dive verification surface
ProviderConnectionListItem:
type: object
required:
- connectionId
- displayName
- provider
- connectionType
- isDefault
- consentStatus
- verificationStatus
- legacyStatusVisibility
- legacyHealthVisibility
- primaryInspectUrl
properties:
connectionId:
type: integer
tenantLabel:
type:
- string
- 'null'
displayName:
type: string
provider:
type: string
connectionType:
type: string
isDefault:
type: boolean
consentStatus:
type: string
verificationStatus:
type: string
legacyStatusVisibility:
$ref: '#/components/schemas/VisibilityMode'
legacyHealthVisibility:
$ref: '#/components/schemas/VisibilityMode'
primaryInspectUrl:
type: string
ProviderConnectionListTruthBundle:
type: object
required:
- rows
properties:
rows:
type: array
items:
$ref: '#/components/schemas/ProviderConnectionListItem'
ProviderConnectionDetailTruthModel:
type: object
required:
- connectionId
- displayName
- provider
- connectionType
- isDefault
- consentStatus
- verificationStatus
- legacyStatusVisibility
- legacyHealthVisibility
properties:
connectionId:
type: integer
displayName:
type: string
provider:
type: string
connectionType:
type: string
isDefault:
type: boolean
consentStatus:
type: string
verificationStatus:
type: string
lastCheckedAt:
type:
- string
- 'null'
lastErrorReasonCode:
type:
- string
- 'null'
lastErrorMessage:
type:
- string
- 'null'
migrationReviewRequired:
type: boolean
legacyStatusVisibility:
$ref: '#/components/schemas/VisibilityMode'
legacyHealthVisibility:
$ref: '#/components/schemas/VisibilityMode'

View File

@ -0,0 +1,222 @@
# Phase 1 Data Model: Provider Readiness Source-of-Truth Cleanup
## Overview
This feature adds no table, no new enum, and no persisted readiness artifact. It reclassifies which already-stored fields are allowed to act as leading operator truth on tenant and provider connection surfaces.
## Persistent Source Truths
### Tenant
**Purpose**: Tenant identity and lifecycle boundary for all tenant-facing operator surfaces.
**Key fields**:
- `id`
- `workspace_id`
- `external_id`
- `name`
- `status`
- `app_status` (legacy tenant-level projection)
- `rbac_status`
**Relationships**:
- `Tenant` has many `ProviderConnection`
**Validation rules**:
- `status` remains the authoritative tenant lifecycle input and maps through `TenantLifecycle`.
- `app_status` remains persisted but is no longer allowed to act as leading operator truth on targeted tenant surfaces.
- `rbac_status` remains a separate domain and must not substitute for provider readiness.
### ProviderConnection
**Purpose**: Canonical stored provider-connection record for tenant-scoped provider integration state.
**Key fields**:
- `id`
- `tenant_id`
- `workspace_id`
- `provider`
- `display_name`
- `is_default`
- `connection_type`
- `consent_status`
- `verification_status`
- `status` (legacy connection-state projection)
- `health_status` (legacy health projection)
- `last_health_check_at`
- `last_error_reason_code`
- `last_error_message`
- `migration_review_required`
**Relationships**:
- `ProviderConnection` belongs to `Tenant`
- `ProviderConnection` belongs to `Workspace`
- `ProviderConnection` has one `ProviderCredential`
**Validation rules**:
- `consent_status` is the primary stored truth for consent progression.
- `verification_status` is the primary stored truth for current provider verification outcome.
- `status` and `health_status` may remain persisted and updated by existing projections, but targeted operator surfaces must treat them as secondary diagnostics only.
- `is_default` remains the routing anchor for tenant-facing provider summaries where a single connection must be chosen.
### ProviderCredential
**Purpose**: Encrypted credential storage associated with a provider connection.
**Key fields**:
- `provider_connection_id`
- encrypted credential payload fields
**Relationships**:
- `ProviderCredential` belongs to `ProviderConnection`
**Validation rules**:
- This spec does not change credential persistence, mutation rules, or encryption handling.
- Credential presence may continue to influence diagnostic projections, but it is not itself rendered as a leading readiness label on the targeted surfaces.
### OperationRun / Verification Report
**Purpose**: Stored verification evidence used by tenant verification and provider check surfaces.
**Key fields**:
- `tenant_id`
- `type` with `provider.connection.check`
- `status`
- `outcome`
- `context`
- stored verification report payload derived from the run
**Relationships**:
- `OperationRun` belongs to `Tenant` when tenant-bound
**Validation rules**:
- The latest stored verification report remains the deep-dive verification surface for tenant detail.
- This spec does not change run lifecycle, queueing, or verification report persistence.
## Existing Domain State Families
### TenantLifecycle
**Values**:
- `draft`
- `onboarding`
- `active`
- `archived`
**Rule**:
- Lifecycle answers whether the tenant is in the normal lifecycle. It does not answer provider consent, provider verification, or provider readiness.
### ProviderConsentStatus
**Values**:
- `unknown`
- `required`
- `granted`
- `failed`
- `revoked`
**Rule**:
- Consent answers whether the permission or consent step has been completed. It does not prove provider verification health.
### ProviderVerificationStatus
**Values**:
- `unknown`
- `pending`
- `healthy`
- `degraded`
- `blocked`
- `error`
**Rule**:
- Verification is the primary current provider-state axis for whether a connection has been checked and what that check currently proves.
### Legacy Diagnostic Projections
**Known provider projection values in current surfaces**:
- `status`: commonly `connected`, `needs_consent`, `error`, `disabled`
- `health_status`: commonly `ok`, `degraded`, `down`, `unknown`
**Rule**:
- These values remain diagnostics or projections. They must not compete with consent and verification as primary operator truth on targeted surfaces.
## Derived Surface Contracts
### Tenant List Row Truth (derived, non-persisted)
**Purpose**: Minimal row-level truth for `/admin/tenants`.
| Field | Type | Required | Description |
|------|------|----------|-------------|
| `tenantId` | integer | yes | Tenant identifier |
| `tenantLabel` | string | yes | Tenant name |
| `lifecycle` | string | yes | Lifecycle label derived from `TenantLifecycle` |
| `providerSignal` | object nullable | no | Optional bounded provider summary derived from current truth; may be absent in this slice |
| `legacyAppStatusVisible` | boolean | yes | Must be `false` on default-visible tenant list surfaces |
| `primaryInspectUrl` | string | yes | Canonical tenant detail destination |
**Validation rules**:
- `providerSignal` may be omitted when a truthful single-row summary cannot be stated without inventing a readiness model.
- `legacyAppStatusVisible` must be `false` for the default tenant list configuration.
### Tenant Detail Provider Summary (derived, non-persisted)
**Purpose**: Compact provider-state summary rendered inside the tenant detail `Provider` section.
| Field | Type | Required | Description |
|------|------|----------|-------------|
| `connectionPresence` | enum | yes | `missing`, `configured`, or `default_configured` |
| `ctaUrl` | string | yes | Canonical provider-connections destination |
| `displayName` | string nullable | no | Default or chosen connection display name |
| `provider` | string nullable | no | Provider label |
| `consentStatus` | string nullable | no | Current consent state from the chosen connection |
| `verificationStatus` | string nullable | no | Current verification state from the chosen connection |
| `lastCheckedAt` | string nullable | no | Stored last-check timestamp |
| `lastErrorReasonCode` | string nullable | no | Diagnostic reason code when present |
| `legacyStatus` | string nullable | no | Legacy diagnostic projection |
| `legacyHealthStatus` | string nullable | no | Legacy health projection |
**Validation rules**:
- Consent and verification are the leading fields in this summary.
- Legacy status and health may remain available only as diagnostics.
- When no default Microsoft provider connection exists, the summary must explicitly say action is needed and must not invent a healthy or ready label.
### Provider Connection Surface Truth (derived, non-persisted)
**Purpose**: Shared truth hierarchy for provider connection list, view, and edit surfaces.
| Field | Type | Required | Description |
|------|------|----------|-------------|
| `connectionId` | integer | yes | Provider connection identifier |
| `tenantLabel` | string nullable | no | Tenant label when rendered on tenantless canonical surfaces |
| `displayName` | string | yes | Connection display name |
| `provider` | string | yes | Provider key or label |
| `connectionType` | string | yes | Platform or dedicated |
| `isDefault` | boolean | yes | Default designation |
| `consentStatus` | string | yes | Leading consent truth |
| `verificationStatus` | string | yes | Leading verification truth |
| `legacyStatus` | string nullable | no | Secondary diagnostic projection |
| `legacyHealthStatus` | string nullable | no | Secondary diagnostic projection |
| `migrationReviewRequired` | boolean | yes | Secondary diagnostic flag |
| `lastCheckedAt` | string nullable | no | Last stored check timestamp |
| `lastErrorReasonCode` | string nullable | no | Stored diagnostic reason code |
**Validation rules**:
- List, view, and edit surfaces must display `consentStatus` and `verificationStatus` before any legacy projection fields.
- Legacy fields may remain filterable or visible only when clearly marked and positioned as diagnostics.
- `connectionType` and `isDefault` remain supporting facts, not substitutes for readiness.
## Surface Truth Hierarchy Rules
1. Tenant lifecycle is the primary tenant-state axis.
2. Provider consent and provider verification are the primary provider-state axes.
3. RBAC remains a separate tenant-management domain.
4. Legacy tenant app status, provider status, and provider health are diagnostic-only on targeted surfaces.
5. No derived surface contract in this feature may collapse `active`, `connected`, or `consented` into a synthetic `ready` value.
## No New Persistence
- No new table is introduced.
- No new enum or reason family is introduced.
- No new persisted readiness summary is introduced.
- Existing persisted legacy fields remain for compatibility, but their surface role is reduced.

View File

@ -0,0 +1,322 @@
# Implementation Plan: Provider Readiness Source-of-Truth Cleanup
**Branch**: `179-provider-truth-cleanup` | **Date**: 2026-04-04 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/179-provider-truth-cleanup/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/179-provider-truth-cleanup/spec.md`
**Note**: This plan keeps the current tenant and provider connection model intact. It cleans up which already-stored fields are allowed to act as leading operator truth and explicitly avoids introducing a new readiness model, new persistence, or a new semantic framework.
## Summary
Harden the existing tenant and provider operator surfaces so lifecycle, consent, and verification become the only leading truth axes on the targeted pages. The implementation will remove `Tenant.app_status` from primary tenant surfaces, repoint the tenant-detail Provider section from legacy connection `status` and `health_status` to current consent and verification, and reorganize provider connection list, view, and edit surfaces so legacy projections become secondary diagnostics. The narrowest safe tenant-list choice in this slice is to omit a new row-level provider readiness signal rather than invent one. The provider list will instead promote consent and verification to the default-visible connection-state axes, while existing legacy fields remain persisted and available only as secondary diagnostics.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials
**Storage**: PostgreSQL unchanged; no new table, column, or persisted artifact is introduced
**Testing**: Pest 4 feature tests and Livewire-style Filament surface tests through Laravel Sail, reusing existing tenant and provider surface tests plus focused Spec 179 truth-cleanup coverage
**Target Platform**: Laravel monolith web application running in Sail locally and containerized Linux environments in staging and production
**Project Type**: web application
**Performance Goals**: Keep existing DB-only render guarantees, avoid adding uncontrolled per-row provider queries to the tenant list, and keep provider and tenant detail rendering bounded to already-loaded or single-record derived state
**Constraints**: No new readiness enum or score, no new persisted truth, no new presenter or taxonomy framework, no authorization widening, no cross-tenant leakage, no new asset pipeline work, and no false equivalence between `active`, `connected`, `consented`, and `ready`
**Scale/Scope**: Two existing Filament resources, five operator-facing surfaces, one existing tenant provider-summary partial, one tenant verification widget, three legacy badge domains under review, and a focused regression pack around truth hierarchy and filter semantics
## Constitution Check
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | The feature changes only read-time presentation of already-stored tenant and provider truth. No snapshot or inventory contract changes are introduced. |
| Read/write separation | PASS | PASS | No new mutation path, preview flow, or destructive action is added. Existing provider and tenant mutations remain unchanged. |
| Graph contract path | N/A | N/A | No Graph contract or `config/graph_contracts.php` change is required. |
| Deterministic capabilities | PASS | PASS | Existing provider view/manage and tenant membership rules remain authoritative. |
| RBAC-UX authorization semantics | PASS | PASS | All touched surfaces remain in `/admin`, preserve tenant and workspace scoping, keep non-members at `404`, and keep member-without-capability behavior unchanged. |
| Workspace and tenant isolation | PASS | PASS | No new route or query broadens tenant visibility. Canonical tenantless provider routes remain tenant-aware and scoped. |
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` type or queueing behavior is introduced. Existing verification and provider-run actions keep their current semantics. |
| Data minimization | PASS | PASS | No new persisted data, logs, or surface payloads are introduced. |
| Proportionality / no premature abstraction | PASS | PASS | The plan reuses existing resources, helpers, and partials instead of adding a new readiness service, presenter, or DTO layer. |
| Persisted truth / behavioral state | PASS | PASS | No new state family or table is planned. Existing legacy fields remain persisted for compatibility but lose leading surface prominence. |
| UI semantics / few layers | PASS | PASS | The plan uses direct lifecycle, consent, and verification truth instead of introducing a new readiness interpreter. |
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge semantics remain centralized. Where provider consent or verification need status-like badges, the plan extends `BadgeCatalog` and `BadgeRenderer` through narrow centralized mappings instead of page-local labels or a synthetic readiness domain. |
| Filament-native UI / Action Surface Contract | PASS | PASS | Existing Filament resources, sections, tables, infolists, view entries, and action groups are reused. No redundant inspect action or empty group is introduced. |
| Filament UX-001 | PASS | PASS | No new screen type is introduced. Existing list, view, and edit surfaces remain within current Filament layout patterns while truth hierarchy changes inside those sections. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design stays within the current Filament v5 and Livewire v4 stack. |
| Provider registration location | PASS | PASS | No provider or panel registration change is required; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | `TenantResource` remains globally searchable and already has view and edit pages. `ProviderConnectionResource` remains non-globally-searchable. |
| Destructive action safety | PASS | PASS | No new destructive action is introduced. Existing archive, restore, force-delete, credential deletion, and disable flows remain confirmed and capability-gated. |
| Asset strategy | PASS | PASS | No new asset bundle or deploy-time `filament:assets` change is required. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds focused regression coverage for default-visible truth hierarchy, filter semantics, and non-readiness interpretation. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/179-provider-truth-cleanup/research.md`.
Key decisions:
- Remove tenant-list `app_status` prominence entirely instead of replacing it with a premature readiness badge.
- Reuse the existing tenant-detail Provider section and `providerConnectionState()` helper, but make consent and verification the leading provider summary fields.
- Keep `TenantVerificationReport` as the verification deep-dive and do not repurpose tenant operability gating as provider readiness truth.
- Promote provider connection `consent_status` and `verification_status` to the lists default-visible current-state axes.
- Split provider connection detail and edit pages into current-state truth versus diagnostics rather than one mixed `Status` section.
- Keep legacy persisted fields and existing projection writers intact; this slice is presentation cleanup only.
- Update provider filters so the main operator filter language follows consent and verification rather than legacy status projections.
- Extend existing surface-truth tests and add one focused provider-surface truth test instead of introducing a broader test framework.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/179-provider-truth-cleanup/`:
- `research.md`: planning decisions and rejected alternatives
- `data-model.md`: persistent source truths and derived surface truth contracts
- `contracts/provider-truth-cleanup.openapi.yaml`: internal route and surface contract for tenant and provider truth cleanup
- `quickstart.md`: focused implementation and validation workflow
Design highlights:
- The tenant list will remain lifecycle-led and will not gain a new speculative readiness badge in this slice.
- `TenantResource::providerConnectionState()` and the shared Provider infolist entry will be repointed to current consent and verification truth, with legacy `status` and `health_status` relegated to diagnostics.
- `Tenant.app_status` will be removed from the leading tenant-list and tenant-detail operator contract rather than simply hidden behind a toggle.
- `ProviderConnectionResource` will make consent and verification the default-visible list columns and primary detail/edit state section, while legacy status and health become secondary diagnostics.
- No new readiness framework, readiness enum, or provider-state presenter will be introduced. Existing badge infrastructure will be extended only as needed with narrow centralized mappings for provider consent and provider verification, while legacy lifecycle and diagnostic mappings remain centralized.
- Existing provider mutation, verification, and authorization behavior stays intact; only the truth hierarchy on touched surfaces changes.
## Phase 1 — Agent Context Update
Run after artifact generation:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Project Structure
### Documentation (this feature)
```text
specs/179-provider-truth-cleanup/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── provider-truth-cleanup.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Resources/
│ │ ├── TenantResource.php
│ │ ├── TenantResource/
│ │ │ └── Pages/
│ │ │ ├── ListTenants.php
│ │ │ └── ViewTenant.php
│ │ ├── ProviderConnectionResource.php
│ │ └── ProviderConnectionResource/
│ │ └── Pages/
│ │ ├── ListProviderConnections.php
│ │ ├── ViewProviderConnection.php
│ │ └── EditProviderConnection.php
│ └── Widgets/
│ └── Tenant/
│ └── TenantVerificationReport.php
├── Models/
│ ├── Tenant.php
│ ├── ProviderConnection.php
│ ├── ProviderCredential.php
│ └── OperationRun.php
├── Services/
│ └── Tenants/
│ └── TenantOperabilityService.php
└── Support/
├── Badges/
│ ├── BadgeCatalog.php
│ ├── BadgeDomain.php
│ └── Domains/
│ ├── TenantAppStatusBadge.php
│ ├── ProviderConnectionStatusBadge.php
│ └── ProviderConnectionHealthBadge.php
├── Providers/
│ ├── ProviderConsentStatus.php
│ └── ProviderVerificationStatus.php
└── Tenants/
└── TenantLifecycle.php
resources/
└── views/
└── filament/
├── infolists/
│ └── entries/
│ └── provider-connection-state.blade.php
└── widgets/
└── tenant/
└── tenant-verification-report.blade.php
tests/
├── Feature/
│ ├── Filament/
│ │ ├── TenantLifecycleStatusDomainSeparationTest.php
│ │ ├── TenantTruthCleanupSpec179Test.php
│ │ └── ProviderConnectionsDbOnlyTest.php
│ ├── ProviderConnections/
│ │ ├── ProviderConnectionTruthCleanupSpec179Test.php
│ │ ├── ProviderConnectionListAuthorizationTest.php
│ │ ├── ProviderConnectionAuthorizationTest.php
│ │ └── RequiredFiltersTest.php
│ ├── Tenants/
│ │ └── TenantProviderConnectionsCtaTest.php
│ └── Rbac/
│ └── TenantResourceAuthorizationTest.php
└── Unit/
└── Badges/
├── TenantBadgesTest.php
└── ProviderConnectionBadgesTest.php
```
**Structure Decision**: Keep the feature entirely inside the existing Laravel/Filament monolith. Update the current resources, shared provider-summary partial, tenant verification widget contract, and focused test files instead of creating a new provider-readiness subsystem.
## Complexity Tracking
> No Constitution Check violations are planned. No exception or bloat trigger is currently justified.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |
## Proportionality Review
> No new enum, persisted entity, abstraction layer, taxonomy, or cross-domain UI framework is planned in this slice.
- **Current operator problem**: Current tenant and provider surfaces let legacy status fields sound more authoritative than current lifecycle, consent, and verification truth.
- **Existing structure is insufficient because**: The model already stores better truth, but the resources and shared provider-summary partial still foreground the wrong fields.
- **Narrowest correct implementation**: Rewire the existing tenant and provider surfaces to consume the current truth hierarchy without inventing a new readiness model or dropping persisted compatibility fields.
- **Ownership cost created**: Focused resource updates, one shared partial rewrite, and a small regression suite for surface truth hierarchy.
- **Alternative intentionally rejected**: A new readiness enum, new presenter layer, or schema cleanup.
- **Release truth**: Current-release truth cleanup.
## Implementation Strategy
### Phase A — Remove Legacy Tenant App Status From Primary Tenant Surfaces
**Goal**: Make tenant lifecycle the only default-visible tenant status axis on the tenant list and tenant detail identity block.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Filament/Resources/TenantResource.php` | Remove `app_status` from the default-visible tenant list column set and stop treating it as a primary list filter. Keep lifecycle and other tenant identity fields intact. |
| A.2 | `app/Filament/Resources/TenantResource.php` | Remove `app_status` from the tenant-detail `Identity` sections leading status contract rather than simply hiding it behind a toggle. |
| A.3 | `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` and `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` | Prove tenant list and tenant detail no longer use `app_status` as primary operator truth. |
### Phase B — Repoint Tenant Detail Provider Summary To Current Provider Truth
**Goal**: Make tenant detail show provider consent and verification rather than legacy provider connection status and health.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Filament/Resources/TenantResource.php` | Refactor `providerConnectionState()` to derive provider summary fields from the chosen Microsoft connections `consent_status`, `verification_status`, `is_default`, `last_health_check_at`, and `last_error_reason_code`, while keeping missing-connection handling explicit. |
| B.2 | `resources/views/filament/infolists/entries/provider-connection-state.blade.php` | Rewrite the Provider summary entry so consent and verification are the leading displayed values and legacy `status` and `health_status` move to optional diagnostics. |
| B.3 | `app/Filament/Widgets/Tenant/TenantVerificationReport.php` and its Blade view | Keep the existing verification widget as the deep-dive stored-report surface and ensure the surrounding tenant-detail semantics do not duplicate or contradict it. |
| B.4 | `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php` and `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` | Prove the tenant-detail provider CTA remains correct and the Provider section no longer implies readiness from legacy fields. |
### Phase C — Promote Consent And Verification On Provider Connection List Surfaces
**Goal**: Make the provider connections list answer the current provider-state questions from consent and verification first.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Filament/Resources/ProviderConnectionResource.php` | Add default-visible consent and verification columns using existing label helpers and keep connection type and default designation as supporting facts. |
| C.2 | `app/Filament/Resources/ProviderConnectionResource.php` | Move legacy `status` and `health_status` columns to toggleable or explicitly diagnostic visibility and adjust visible-column defaults accordingly. |
| C.3 | `app/Filament/Resources/ProviderConnectionResource.php` | Replace or demote core `status` and `health_status` filters so the leading filter language becomes consent and verification, with legacy filters retained only if clearly diagnostic. |
| C.4 | `tests/Feature/ProviderConnections/RequiredFiltersTest.php`, `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`, and `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` | Prove default-visible columns and filters follow the new hierarchy without breaking DB-only rendering. |
### Phase D — Split Provider Connection View And Edit Into Current State Versus Diagnostics
**Goal**: Prevent provider connection detail and edit pages from showing current and legacy status axes as peers.
| Step | File | Change |
|------|------|--------|
| D.1 | `app/Filament/Resources/ProviderConnectionResource.php` | Replace the mixed `Status` infolist section with a primary current-state section for consent and verification and a secondary diagnostics section for legacy status, legacy health, migration review, and last-error fields. |
| D.2 | `app/Filament/Resources/ProviderConnectionResource.php` | Apply the same separation to the edit forms read-only state context so mutations happen in the presence of current consent and verification truth, with legacy fields shown only as diagnostics if retained. |
| D.3 | `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` | Prove current state and diagnostics are visually and semantically separate on provider connection view and edit surfaces. |
### Phase E — Preserve Central Truth Mapping Without Adding A New Badge Framework
**Goal**: Keep badge and presentation semantics centralized while avoiding a new readiness taxonomy.
| Step | File | Change |
|------|------|--------|
| E.1 | `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/Domains/ProviderConsentStatusBadge.php`, `app/Support/Badges/Domains/ProviderVerificationStatusBadge.php`, and existing legacy badge-domain classes | Add narrow centralized badge mappings for `consent_status` and `verification_status`, keep legacy app-status or connection-status mappings diagnostic-only, and do not introduce a synthetic readiness badge domain in this slice. |
| E.2 | `tests/Unit/Badges/TenantBadgesTest.php` and `tests/Unit/Badges/ProviderConnectionBadgesTest.php` | Update unit badge coverage for the centralized provider consent and provider verification mappings and keep legacy diagnostic mapping expectations explicit. |
### Phase F — Protect Authorization, DB-only Rendering, And Formatting
**Goal**: Keep the cleanup semantically tight and operationally safe.
| Step | File | Change |
|------|------|--------|
| F.1 | Existing tenant and provider authorization tests | Re-run and extend current authorization coverage only where surface visibility or filter behavior changes could affect scope boundaries. |
| F.2 | Focused Pest verification pack | Add or update tests proving legacy-status suppression, provider current-state promotion, filter truth alignment, DB-only rendering, and denial semantics. |
| F.3 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Apply formatting and run the minimum Sail-first verification pack before implementation is considered complete. |
## Key Design Decisions
### D-001 — Do not create a new readiness model in a truth-cleanup spec
The current feature exists to stop misleading primary surfaces, not to coin a new readiness taxonomy. Lifecycle, consent, and verification already provide the necessary authoritative inputs.
### D-002 — The tenant list should consciously omit provider readiness rather than compress it unsafely
Any row-level provider readiness badge would require non-trivial aggregation and semantic compression across provider connections. Omitting that signal is safer and matches the specs fallback allowance.
### D-003 — Reuse the existing tenant-detail Provider section instead of adding another summary layer
`providerConnectionState()` and the shared infolist entry already own the tenant-detail provider summary. Repointing that contract is narrower than building a new widget or presenter.
### D-004 — Provider detail pages need one current-state section and one diagnostics section
Equal-weight status groupings are the core current UI problem. Separating current state from diagnostics is the smallest structural change that makes the leading truth obvious.
### D-005 — Keep legacy persisted fields, but remove their authority on targeted surfaces
The model and some jobs still project `status` and `health_status`, and tenant `app_status` still exists in storage. This slice preserves compatibility while eliminating misleading prominence.
### D-006 — Do not use tenant operability gating as provider readiness truth
`TenantOperabilityService` answers lifecycle and capability eligibility questions. It is not a substitute for consent or verification health and must stay separate in both implementation and messaging.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Removing `app_status` from the tenant list may reduce scanability before a later readiness spec lands | Medium | Medium | Keep the tenant list lifecycle-led, keep the provider CTA obvious on tenant detail, and treat any future list signal as follow-up work rather than a rushed addition now. |
| Tenant detail may still show the wrong provider connection when multiple connections exist | High | Medium | Continue to prefer the default Microsoft connection when present and render an explicit missing or configured state rather than an optimistic readiness label. |
| Provider list could retain legacy semantics if filters change more slowly than columns | High | Medium | Update both default-visible columns and core filter set together and protect them with `RequiredFiltersTest` plus a focused truth test. |
| Existing tests currently asserting `app_status` visibility will fail noisily | Medium | High | Update the intentional tenant truth-separation test first and add a spec-specific replacement that encodes the new contract. |
| New tenant-list provider queries could create unnecessary DB cost | Medium | Low | Avoid adding a new per-row provider signal on the tenant list in this slice. |
## Test Strategy
- Update `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` so it protects the new tenant truth hierarchy instead of asserting legacy `app_status` prominence.
- Add `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` to cover tenant list default-visible truth and tenant detail Provider section semantics.
- Add `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` to cover provider connection list, view, and edit truth hierarchy.
- Update `tests/Feature/ProviderConnections/RequiredFiltersTest.php` so the core filter contract follows consent and verification instead of legacy status or health.
- Update `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php` so visible-column expectations and DB-only rendering stay valid after the list contract changes.
- Keep `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php` and existing authorization tests in the focused pack so route and scope semantics do not regress.
- Keep tenant global-search safety and provider-connection global-search exclusion in the focused pack so discovery behavior does not regress.
- Finish the feature with an explicit no-migration and no-new-persistence diff validation gate in addition to runtime regression coverage.
- Run the focused Sail-first pack defined in `quickstart.md`, then run `vendor/bin/sail bin pint --dirty --format agent` before closing implementation.
## Constitution Check (Post-Design)
Re-check result: PASS.
- Livewire v4.0+ compliance: preserved because the plan stays within the current Filament v5 + Livewire v4 resources, widgets, and pages.
- Provider registration location: unchanged; no panel or provider registration work is needed beyond the existing `bootstrap/providers.php` setup.
- Global-searchable resources: `TenantResource` remains globally searchable and already has both view and edit pages; `ProviderConnectionResource` remains non-globally-searchable, so no global-search conflict is introduced.
- Destructive actions: no new destructive action is added. Existing tenant archive, restore, and force-delete flows and existing provider credential or connection-state mutations remain confirmation-protected and capability-gated.
- Asset strategy: unchanged; no new assets are introduced and the deploy-time `php artisan filament:assets` process remains unaffected.
- Testing plan: focused Pest coverage will protect tenant legacy-status suppression, tenant-detail provider summary truth, provider current-state promotion, filter hierarchy, DB-only rendering, and unchanged authorization boundaries.

View File

@ -0,0 +1,147 @@
# Quickstart: Provider Readiness Source-of-Truth Cleanup
## Goal
Validate that tenant and provider operator surfaces no longer elevate `Tenant.app_status`, `ProviderConnection.status`, or `ProviderConnection.health_status` as leading truth, and that lifecycle, consent, and verification now answer the primary operator questions.
## Prerequisites
1. Start Sail.
2. Prepare one workspace member with at least one visible tenant and provider connection management access.
3. Seed or create tenant scenarios for:
- active tenant with `app_status` populated but provider verification `unknown`
- onboarding tenant with granted consent and blocked verification
- tenant with no default Microsoft provider connection
4. Seed or create provider connection scenarios for:
- consent `granted`, verification `degraded`, legacy `status=connected`, legacy `health_status=ok`
- consent `required` or `revoked`, verification `blocked`, legacy `status` still optimistic
- configured connection that has never been verified
- disabled connection with retained legacy status or health values
5. Prepare one non-member or cross-workspace actor for deny-as-not-found checks.
## Implementation Validation Order
### 1. Run the current baseline tenant and provider surface tests
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/RequiredFiltersTest.php
vendor/bin/sail artisan test --compact tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php
```
Expected outcome:
- Existing tenant detail, provider connection pages, canonical tenantless provider route, and provider CTA behavior still render correctly before the cleanup changes are applied.
### 2. Run focused tenant truth-cleanup coverage
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php
```
Expected outcome:
- Tenant list no longer shows `app_status` as default-visible truth.
- Tenant detail keeps lifecycle separate from provider consent and verification.
- Tenant detail Provider section stops leading with legacy `status` and `health_status`.
### 3. Run focused provider truth-cleanup coverage
```bash
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/RequiredFiltersTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php
```
Expected outcome:
- Provider connection list promotes consent and verification to the default-visible columns.
- Provider connection view and edit pages show current state separately from diagnostics.
- Legacy `status` and `health_status` remain secondary or hidden by default.
### 4. Re-run authorization and discovery-safety coverage on touched resources
```bash
vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantResourceAuthorizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantScopingTest.php
```
Expected outcome:
- Workspace and tenant scoping remain unchanged.
- Non-members still receive deny-as-not-found behavior.
- Members without capability do not gain new visibility or mutation access.
- Tenant global search remains workspace-safe.
- Provider connections remain excluded from global search.
### 5. Run badge-mapping coverage
```bash
vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php
vendor/bin/sail artisan test --compact tests/Unit/Badges/ProviderConnectionBadgesTest.php
```
Expected outcome:
- Lifecycle, provider consent, provider verification, and retained legacy diagnostic badges resolve through centralized badge mappings only.
### 6. Format touched files
```bash
vendor/bin/sail bin pint --dirty --format agent
```
Expected outcome:
- All touched implementation files conform to project formatting rules.
### 7. Run the final focused verification pack
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/RequiredFiltersTest.php
vendor/bin/sail artisan test --compact tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php
vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantResourceAuthorizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantScopingTest.php
vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php
vendor/bin/sail artisan test --compact tests/Unit/Badges/ProviderConnectionBadgesTest.php
```
Expected outcome:
- The targeted tenant and provider surfaces keep truthful status hierarchy, keep DB-only rendering where already promised, and preserve existing authorization boundaries.
### 8. Validate no migration and no new persisted truth were introduced
```bash
git diff --name-only -- database/migrations app/Models app/Support/Providers app/Support/Badges/BadgeDomain.php app/Support/Badges/BadgeCatalog.php app/Support/Badges/Domains/ProviderConsentStatusBadge.php app/Support/Badges/Domains/ProviderVerificationStatusBadge.php app/Support/Badges/Domains/TenantAppStatusBadge.php app/Support/Badges/Domains/ProviderConnectionStatusBadge.php app/Support/Badges/Domains/ProviderConnectionHealthBadge.php specs/179-provider-truth-cleanup
```
Expected outcome:
- No new migration file is introduced for this feature.
- No new persisted readiness artifact or new provider-status family appears outside the approved centralized badge mapping work.
- Central badge changes are limited to the approved consent, verification, and retained diagnostic badge mappers.
## Manual Smoke Check
1. Open `/admin/tenants` and confirm lifecycle remains visible while `app_status` is no longer a default-visible status badge.
2. Open one tenant detail page and confirm the `Provider` section now leads with consent and verification, not connection `status` and `health`.
3. Confirm the `Verification report` widget still provides the deeper stored verification surface and does not depend on outbound HTTP at render time.
4. Open `/admin/provider-connections` and confirm consent and verification are the primary default-visible state columns.
5. Confirm any retained legacy `status` or `health` values are secondary diagnostics rather than peer badges.
6. Open a provider connection view page and confirm `configured`, `connected`, or `consented` no longer read as equivalent to verified or ready.
7. Open a provider connection edit page and confirm current consent and verification context are visible before any mutation, while diagnostics remain secondary.
8. Repeat one tenant and one provider URL as a non-member or out-of-scope actor and confirm deny-as-not-found behavior still holds.
## Non-Goals For This Slice
- No database migration.
- No new readiness enum, score, or persisted summary.
- No change to verification queueing, `OperationRun` semantics, or provider mutation workflows.
- No removal of legacy database fields or projection writers.

View File

@ -0,0 +1,74 @@
# Phase 0 Research: Provider Readiness Source-of-Truth Cleanup
## Decision: Remove legacy `app_status` from tenant list primary truth and do not replace it with a new row-level readiness badge in this slice
**Rationale**: The current tenant list exposes `app_status` as a default-visible badge and filter even though the feature goal is to stop projecting frozen or semantically stale provider truth from tenant-level legacy fields. A new per-row provider readiness badge would require choosing one provider connection, compressing multi-connection state, and implicitly inventing a readiness model. The narrowest safe move is to let the tenant list remain lifecycle-led and to consciously omit provider readiness from the row when it cannot be stated without semantic overreach.
**Alternatives considered**:
- Introduce a new tenant readiness badge on the list: rejected because it would create a new semantic layer before the later readiness spec.
- Keep `app_status` but toggle it off by default: rejected because the spec explicitly warns that hidden-by-default legacy truth still leaks back into operator interpretation.
## Decision: Reuse the existing tenant Provider section, but repoint it from legacy `status` and `health_status` to consent and verification
**Rationale**: Tenant detail already has a dedicated `Provider` section backed by `TenantResource::providerConnectionState()` and the shared Blade entry `provider-connection-state.blade.php`. That section is the narrowest place to present current provider truth without inventing a new widget or presenter. The helper should continue to resolve the default Microsoft provider connection or a missing-connection state, but its leading fields should become `consent_status` and `verification_status`, with legacy status and health demoted to optional diagnostics.
**Alternatives considered**:
- Build a new tenant provider summary widget: rejected because the current section already exists and the spec does not justify another surface layer.
- Remove the Provider section entirely and rely only on the verification widget: rejected because the spec requires clear consent and verification semantics, and the verification widget is a deep-dive report, not the only summary contract.
## Decision: Keep `TenantVerificationReport` as the verification deep-dive and do not turn `TenantOperability` into provider truth
**Rationale**: `TenantVerificationReport` already gives tenant detail a DB-only verification deep-dive using the latest `provider.connection.check` run. `TenantOperabilityService` and `verificationReadinessOutcome()` answer whether a verification action may be started in a given lifecycle and capability context, not whether the provider is operationally healthy. The cleanup should continue to use the verification report for stored verification detail and must not promote operability gating into readiness truth.
**Alternatives considered**:
- Use `TenantOperabilityDecision` as a tenant provider readiness signal: rejected because it mixes lifecycle and permission gating with provider-state truth.
- Collapse verification detail into the Provider section and remove the verification widget: rejected because the widget already provides the deeper stored-report surface and no new readiness model is needed.
## Decision: Promote `consent_status` and `verification_status` to the default-visible provider connection list axes
**Rationale**: The provider connections list currently defaults to `status` and `health_status`, even though the model already stores `consent_status` and `verification_status` and the resource already has label helpers for them. Surfacing consent and verification in the table is the clearest way to answer whether a connection is consented and whether it has been checked. Legacy `status` and `health_status` may remain available as hidden diagnostics for compatibility, but they should stop being the primary scan path.
**Alternatives considered**:
- Keep `status` and `health_status` visible because they are shorter to scan: rejected because that preserves the competing-truth problem the spec exists to remove.
- Remove legacy columns completely from the list: rejected because the fields may still help diagnostics and internal compatibility during the transition.
## Decision: Split provider connection detail and edit surfaces into current state versus diagnostics instead of one equal-weight Status section
**Rationale**: The current provider connection infolist and form both render consent, verification, legacy status, and health at the same hierarchy level in one `Status` section. The cleanup should create a primary current-state section for consent and verification, and a separate diagnostics section for legacy status, health, migration review, and last-error metadata. This keeps the existing resource and existing fields while making the leading truth explicit.
**Alternatives considered**:
- Keep one Status section and merely reorder the fields: rejected because equal visual grouping would still overstate the legacy fields.
- Delete legacy fields from detail and edit immediately: rejected because the spec allows the fields to remain as long as they stop acting like leading truth.
## Decision: Extend the existing badge catalog with narrow provider consent and provider verification mappings while keeping legacy badge domains diagnostic-only
**Rationale**: BADGE-001 requires status-like values to render through `BadgeCatalog` and `BadgeRenderer`. `BadgeCatalog` already centralizes legacy `TenantAppStatus`, `ProviderConnectionStatus`, and `ProviderConnectionHealth` mappings, so the narrowest compliant move is to add centralized provider consent and provider verification mappings inside the existing badge system. This keeps lifecycle, consent, verification, and legacy diagnostics on one central semantic path without creating a synthetic `Ready` or `ProviderReadiness` domain.
**Alternatives considered**:
- Continue using plain labels or local helper output for consent and verification: rejected because BADGE-001 requires centralized status-like badge semantics.
- Add a new `ProviderReadiness` badge domain: rejected because it would front-run the later readiness spec and violate proportionality.
- Remove legacy badge mappers entirely: rejected because diagnostic surfaces and internal compatibility still reference them.
## Decision: Keep legacy persisted fields and current projection writers intact in this slice
**Rationale**: `ProviderConnection` still projects `status` and `health_status` during classification and enable or disable flows, and tenant records still persist `app_status`. The spec explicitly forbids a schema change or aggressive technical removal. The correct scope is presentation cleanup first, not domain-field deletion.
**Alternatives considered**:
- Drop legacy columns now: rejected because existing jobs, projections, audit metadata, and compatibility paths still write or reference them.
- Stop writing the fields immediately as part of this spec: rejected because it would turn a surface-truth cleanup into a broader behavioral change.
## Decision: Update provider list filters to follow the leading truth hierarchy
**Rationale**: The current provider list filter set centers `status` and `health_status`. If the tables leading truth becomes consent and verification, the lists core filters should follow the same hierarchy. Legacy filters may remain only as explicitly diagnostic filters, not as the primary filter language for current provider state.
**Alternatives considered**:
- Leave legacy filters untouched while changing only columns: rejected because that keeps the old semantics in the main operator interaction path.
- Remove all legacy filters outright: rejected because diagnostic filtering may still be useful while the fields remain persisted.
## Decision: Extend existing truth-separation tests and add one focused provider-surface truth test instead of inventing a large new test harness
**Rationale**: The repo already has `TenantLifecycleStatusDomainSeparationTest`, `ProviderConnectionsDbOnlyTest`, `RequiredFiltersTest`, and badge tests. The specs main risk is business-truth regression on a few surfaces. Focused feature tests that assert default-visible fields, section hierarchy, and filter names are enough to protect this contract without creating a broad new testing abstraction.
**Alternatives considered**:
- Rely on manual browser inspection only: rejected because truth regressions on default-visible surfaces are easy to reintroduce.
- Create a large generic presenter test framework first: rejected because the cleanup intentionally avoids adding a new presenter layer.

View File

@ -0,0 +1,226 @@
# Feature Specification: Spec 179 - Provider Readiness Source-of-Truth Cleanup
**Feature Branch**: `179-provider-truth-cleanup`
**Created**: 2026-04-04
**Status**: Draft
**Input**: User description: "Spec 179 — Provider Readiness Source-of-Truth Cleanup"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace + tenant
- **Primary Routes**:
- `/admin/tenants`
- `/admin/tenants/{tenant}`
- `/admin/provider-connections`
- `/admin/provider-connections/{record}`
- `/admin/provider-connections/{record}/edit`
- **Data Ownership**:
- Tenant-owned truth already exists in tenant lifecycle state, provider connection consent state, provider connection verification state, provider connection metadata, and stored verification outputs tied to a tenant.
- Workspace-owned context remains the selected workspace and its membership filter, which determines which tenants and provider connections are visible in `/admin`.
- This feature introduces no new persisted truth. It only removes misleading prominence from existing legacy status fields and reorders presentation of already-stored truth.
- **RBAC**:
- Workspace membership remains required for the tenant and provider resources in `/admin`.
- Tenant membership remains the isolation boundary for tenant and provider records.
- Existing provider view and provider manage capability checks remain authoritative for view and mutation surfaces.
- No authorization broadening is allowed; non-members remain deny-as-not-found and members without capability remain forbidden for protected actions.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Tenant list | CRUD / List-first Resource | Full-row click into tenant view | required | Header `Add tenant`; non-inspect row actions stay in `More` | Existing archive, restore, and force-delete actions remain secondary in `More`; unchanged by this spec | `/admin/tenants` | `/admin/tenants/{tenant}` | Active workspace, tenant name, environment, and existing tenant scope | Tenants / Tenant | Tenant lifecycle, plus an optional bounded provider signal only when it is derived from current provider truth | none |
| Tenant view | Detail / view-first resource | Tenant view page itself | forbidden | Header `ActionGroup` remains the secondary action container | Existing destructive lifecycle actions stay grouped and confirmed; unchanged by this spec | `/admin/tenants` | `/admin/tenants/{tenant}` | Tenant identity, workspace context, and existing tenant-scoped widgets | Tenant | Tenant lifecycle first; provider consent and provider verification separate from lifecycle and RBAC | none |
| Provider connections list | CRUD / List-first Resource | Full-row click into provider connection view | required | Header `New connection`; operational and management actions stay in row `More` | Existing set-default, enable or disable, and credential mutations stay secondary and confirmed where already required | `/admin/provider-connections` | `/admin/provider-connections/{record}` | Active workspace plus tenant filter or tenant query context, tenant name, provider name, and default marker | Provider Connections / Provider Connection | Consent state and verification state, not legacy connection status or health | Tenantless canonical route with tenant-aware filtering |
| Provider connection view | Detail / view-first resource | Provider connection view page itself | forbidden | Header actions and grouped header mutations remain the secondary action area | Existing credential and connection-state mutations remain grouped, confirmed, and capability-gated | `/admin/provider-connections` | `/admin/provider-connections/{record}` | Tenant identity, provider identity, connection type, and default marker | Provider Connection | Consent state and verification state as the leading diagnosis | none |
| Provider connection edit | Edit form | Provider connection edit page itself | forbidden | Save and cancel remain the form actions; grouped header actions remain secondary | Existing credential and connection-state mutations remain grouped, confirmed, and capability-gated | `/admin/provider-connections` | `/admin/provider-connections/{record}/edit` | Tenant identity, provider identity, connection type, and default marker | Provider Connection | Current consent and verification context before any configuration mutation; legacy fields are not allowed to dominate the form | 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 |
|---|---|---|---|---|---|---|---|---|---|
| Tenant list | Workspace or tenant operator | List | Which tenants are in a normal lifecycle state, and is there any trustworthy provider concern I should notice without assuming readiness? | Tenant name, lifecycle, environment, and either a bounded provider signal from current provider truth or no provider signal at all | Legacy app-status projections and frozen provider surrogates are not default-visible | lifecycle, optional provider consent or verification summary | none on the list itself; existing row actions remain unchanged | Open tenant, Add tenant | Existing archive, restore, and force-delete actions remain secondary and are not reinterpreted as provider readiness signals |
| Tenant view | Tenant operator | Detail | What is this tenant's lifecycle, and what does the current provider base actually prove? | Lifecycle summary, provider consent, provider verification, verification summary, and RBAC shown as a separate domain when present | Legacy app status, historical provider projections, and raw verification internals stay diagnostic-only if retained at all | lifecycle, provider consent, provider verification, RBAC status as a separate domain | Existing verification and tenant management actions remain unchanged | Provider connections, Verify configuration, Edit, Grant admin consent, Open in Entra | Existing archive, restore, and force-delete actions remain grouped, confirmed, and clearly separate from provider truth |
| Provider connections list | Tenant operator or tenant admin | List | Which connections are consented, which are verified, and which still need attention? | Tenant, provider, default marker, consent state, verification state, and connection type when useful | Legacy connection status, legacy health, migration-review metadata, and raw error text stay secondary | consent, verification, default designation | Existing provider operations and management actions remain unchanged | Open provider connection, New connection | Set as default, enable or disable connection, dedicated-credential actions, and provider-run actions remain secondary and capability-gated |
| Provider connection view | Tenant operator or tenant admin | Detail | Is this connection only configured, or has it actually been checked and verified? | Consent state, verification state, provider identity, default designation, and next-step links | Legacy status and health, raw identifiers, and low-level technical fields stay secondary | consent, verification, connection type | Existing connection-check and provider-management actions remain unchanged | Check connection, Grant admin consent, Edit, View last check run | Enable dedicated override, rotate or delete dedicated credential, revert to platform, enable or disable connection, and set default remain grouped, confirmed, and authorized |
| Provider connection edit | Tenant admin | Edit | What can I safely change, and what do the current consent and verification states tell me before I mutate this connection? | Editable configuration, consent state, verification state, save and cancel, and the same tenant scope cues as the view page | Legacy status and health, raw technical metadata, and historical projections stay diagnostic-only if retained | consent, verification, connection type | Existing provider configuration mutation scope remains unchanged | Save changes, Cancel, Check connection, View last check run | Existing dedicated-credential and enable or disable actions remain grouped, confirmed, and authorized |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: No.
- **New persisted entity/table/artifact?**: No.
- **New abstraction?**: No.
- **New enum/state/reason family?**: No.
- **New cross-domain UI framework/taxonomy?**: No.
- **Current operator problem**: A tenant or provider connection can currently look active, connected, or healthy on a leading surface even when the provider basis is unclear, stale, blocked, or semantically contradicted by newer status fields.
- **Existing structure is insufficient because**: The current model already has clearer truth axes for lifecycle, consent, and verification, but primary tenant and provider surfaces still elevate legacy fields such as tenant app status, provider connection status, and provider health as if they were current truth.
- **Narrowest correct implementation**: Rework only the existing tenant and provider presentation layers, default-visible fields, filters, and badge mappings so that lifecycle, consent, and verification become the primary operator-facing truth without inventing a new readiness model.
- **Ownership cost**: The repo takes on focused UI cleanup, badge or filter adjustments, and regression coverage for truth ordering, but no new persistence, taxonomy, or cross-cutting semantic framework.
- **Alternative intentionally rejected**: A new tenant readiness enum, a full readiness assessment, or a new persisted summary artifact is rejected for this slice because the immediate risk is conflicting surface truth, not the absence of another status model.
- **Release truth**: Current-release truth cleanup that also prepares the codebase for later readiness and gating work.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Read truthful tenant surfaces (Priority: P1)
As an operator, I can open the tenant list and tenant view and understand tenant lifecycle separately from provider consent and provider verification, so an `active` tenant does not silently read as provider-ready.
**Why this priority**: The biggest current risk is false confidence on the main tenant-facing surfaces.
**Independent Test**: Can be fully tested by seeding tenants whose lifecycle, legacy app status, consent, and verification states disagree, then rendering the tenant list and tenant view to verify that lifecycle remains separate and legacy app status no longer drives the leading operator truth.
**Acceptance Scenarios**:
1. **Given** a tenant is `active` and its provider verification is `unknown`, **When** the operator opens the tenant list or tenant view, **Then** the surface shows lifecycle without implying the tenant is provider-ready.
2. **Given** a tenant is onboarding, consent is granted, and verification is blocked, **When** the operator opens the tenant view, **Then** onboarding remains the tenant lifecycle truth and the provider issue remains separately visible.
3. **Given** a tenant still has a populated legacy app-status value, **When** the operator opens a primary tenant surface, **Then** that legacy field is not presented as the current leading status.
---
### User Story 2 - Diagnose provider connections from current axes (Priority: P1)
As an operator, I can use provider connection list, view, and edit surfaces to see consent and verification as the leading provider state axes, even when legacy connection status fields still contain data.
**Why this priority**: Provider connection pages are the canonical diagnostic surfaces for this domain and must stop showing parallel state systems as peers.
**Independent Test**: Can be fully tested by seeding provider connections whose legacy `status` and `health_status` disagree with `consent_status` and `verification_status`, then verifying that list, view, and edit surfaces elevate consent and verification while demoting or hiding the legacy fields.
**Acceptance Scenarios**:
1. **Given** a provider connection has consent granted, verification degraded, legacy status `connected`, and legacy health `ok`, **When** the operator opens the provider connections list or detail pages, **Then** consent and degraded verification are the leading truths and the legacy fields do not appear at equal prominence.
2. **Given** a provider connection is configured but never verified, **When** the operator opens its view or edit page, **Then** the page makes clear that the connection is not yet proven healthy.
3. **Given** a provider connection is disabled, **When** the operator opens the list or detail page, **Then** connection-state actions remain secondary and do not override consent or verification semantics.
---
### User Story 3 - Avoid false readiness language across surfaces (Priority: P2)
As an operator, I can move between tenant and provider surfaces without seeing `active`, `connected`, `consented`, or similar labels treated as if they automatically mean `ready`.
**Why this priority**: This slice is a truth cleanup, not a final readiness model, so it must reduce semantic overreach before any follow-up readiness work starts.
**Independent Test**: Can be fully tested by rendering primary tenant and provider surfaces for records that look favorable in one status family but unfavorable in another and verifying that no default-visible wording or badge composition collapses them into one readiness conclusion.
**Acceptance Scenarios**:
1. **Given** a tenant is active and the default provider connection is consented but verification is error or blocked, **When** the operator inspects the tenant and provider surfaces, **Then** neither surface implies that the tenant is operationally ready.
2. **Given** the tenant list does not have enough trustworthy provider truth to summarize one tenant safely, **When** the list renders, **Then** it omits that provider readiness signal instead of inventing a legacy or optimistic substitute.
3. **Given** RBAC status is also shown on the tenant view, **When** the page renders, **Then** RBAC remains a separate domain and does not masquerade as provider readiness.
### Edge Cases
- Legacy app status or legacy provider status fields still contain optimistic values that contradict current verification truth.
- A tenant has no provider connection or has multiple provider connections, so the tenant list cannot safely compress provider truth into one scan-time signal.
- Consent is granted but verification has never been run, so the system must distinguish configured or consented from verified.
- Verification is stale, blocked, degraded, or error while the tenant lifecycle remains active.
- RBAC health is present on the tenant view at the same time as provider truth and must remain visibly separate.
- A non-member or cross-workspace actor attempts to reach tenant or provider surfaces and must continue to see deny-as-not-found behavior without new hints.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new long-running workflow, and no new write path. Existing verification and provider-run actions remain as they are today. The work is limited to truth cleanup on existing operator-facing surfaces.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature is intentionally narrow. It adds no new persistence, abstraction, state family, presenter framework, or readiness taxonomy. It cleans up leading surface truth using already-existing lifecycle, consent, verification, and verification-report data.
**Constitution alignment (OPS-UX):** No new `OperationRun` type, execution surface, or feedback path is introduced. Existing verification and provider-operation flows keep their current run semantics.
**Constitution alignment (RBAC-UX):** The work stays in the admin plane at `/admin` on existing tenant and provider resources. Non-members and out-of-scope users remain deny-as-not-found. In-scope members without capability remain forbidden for protected actions. Server-side authorization remains the source of truth. Tenant global search stays safe because the tenant resource already has view and edit pages; provider connections remain non-globally-searchable for this slice. Existing destructive-like actions keep their current confirmation requirements.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No auth handshake behavior is changed.
**Constitution alignment (BADGE-001):** Status badge semantics remain centralized. This feature updates leading badge usage so tenant lifecycle, provider consent, and provider verification are the primary operator-facing status hierarchy on targeted surfaces. If provider consent or provider verification require new mappings, they MUST be added through `BadgeCatalog` and `BadgeRenderer` inside the existing badge system rather than through page-local labels or a synthetic readiness framework.
**Constitution alignment (UI-FIL-001):** The feature reuses existing Filament tables, infolists, widgets, form sections, action groups, and centralized badge helpers. No local replacement markup or page-local status language is needed. No exception is expected.
**Constitution alignment (UI-NAMING-001):** Operator-facing copy must keep `Lifecycle`, `Consent`, and `Verification` as distinct questions. This slice must not introduce `Ready` as a new leading label. If any legacy field remains visible, it must be named as diagnostic or legacy rather than as primary status.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** Each affected surface keeps one primary inspect or open model, and the surface tables above define collection routes, detail routes, action placement, scope signals, and the default-visible truth. The targeted surfaces must stop treating `app_status`, provider `status`, or provider `health_status` as peer signals to lifecycle, consent, or verification.
**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first. Tenant surfaces show lifecycle and, when warranted, provider truth. Provider surfaces show consent and verification first. Diagnostics such as legacy fields, raw identifiers, or historical projections must remain secondary. Existing mutation scope and safe-execution patterns remain unchanged.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from existing lifecycle, consent, and verification truth is sufficient once legacy prominence is removed. This feature must not add a new readiness interpreter or semantic wrapper. Tests must protect business truth by proving the absence of false readiness and the demotion of conflicting legacy signals.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Affected surfaces keep one primary inspect model, redundant `View` actions remain absent, empty action groups are not introduced, and destructive actions keep their current placement and confirmation rules. UI-FIL-001 is satisfied with no approved exception.
**Constitution alignment (UX-001 — Layout & Information Architecture):** No new screen type is introduced. Tenant view continues to use a view page with widgets and infolist content. Provider connection view continues to use a view page. Provider connection edit remains a section-based edit form. List pages keep search, sort, and filters, but the default-visible status dimensions and core filters must align to leading truth rather than legacy projections.
### Functional Requirements
- **FR-179-001 (Lifecycle remains its own truth)**: Targeted tenant surfaces MUST present tenant lifecycle as a distinct domain and MUST NOT use lifecycle labels to imply provider readiness.
- **FR-179-002 (Tenant app status removed from leading truth)**: Targeted tenant surfaces MUST NOT present `app_status` as a default-visible primary badge, summary field, or equivalent current-status signal.
- **FR-179-003 (Tenant list uses only trustworthy provider signal)**: The tenant list MUST either show a bounded provider signal derived from current provider consent and verification truth or omit provider status for that row. It MUST NOT rely on `app_status` or another legacy projection as the scan-time provider indicator.
- **FR-179-004 (Tenant detail separates provider axes)**: When tenant detail surfaces provider state, they MUST show provider consent and provider verification as separate axes.
- **FR-179-005 (No false readiness on tenant detail)**: Tenant detail MUST NOT allow `active`, `connected`, `configured`, or `consented` signals to read as equivalent to verified, healthy, or ready when verification is unknown, blocked, degraded, error, or absent.
- **FR-179-006 (RBAC remains separate)**: If RBAC status appears on tenant detail, it MUST remain visibly separate from provider consent and provider verification and MUST NOT substitute for provider readiness.
- **FR-179-007 (Provider list leading axes)**: Provider connection list MUST use consent state and verification state as the primary default-visible connection-state axes.
- **FR-179-008 (Legacy provider status demoted)**: Provider connection list, view, and edit surfaces MUST NOT present legacy `status` or `health_status` as equal-priority peer truth next to consent and verification.
- **FR-179-009 (Legacy diagnostics handling)**: If legacy provider `status` or `health_status` remains visible anywhere on the targeted provider surfaces, it MUST be clearly labeled and positioned as secondary diagnostics, historical projection, or technical metadata.
- **FR-179-010 (Provider detail distinguishes configured from proven)**: Provider connection view and edit surfaces MUST make it clear whether a connection is merely configured or consented versus operationally checked and verified.
- **FR-179-011 (Badge truth alignment)**: Centralized badge and presentation mappings used by the targeted tenant and provider surfaces MUST elevate lifecycle, consent, and verification over tenant app status, provider connection status, and provider connection health.
- **FR-179-012 (No conflicting equal-prominence status mosaic)**: Targeted tenant and provider surfaces MUST NOT render multiple semantically overlapping status badges or fields at the same visual level when only one is authoritative for that operator question.
- **FR-179-013 (List filters follow leading truth)**: Default-visible list filters and core list columns on affected surfaces MUST align with the leading truth hierarchy. Legacy status filters may remain only if they are explicitly secondary and do not present themselves as the primary way to judge current provider readiness.
- **FR-179-014 (No new persisted truth)**: The feature MUST ship without a new table, without a new persisted readiness artifact, and without a new status family.
- **FR-179-015 (Authorization boundaries unchanged)**: Workspace scoping, tenant isolation, and capability checks on all touched surfaces MUST remain unchanged. This cleanup MUST NOT widen visibility or bypass existing server-side authorization.
- **FR-179-016 (Global search and discovery stay safe)**: Tenant search behavior may remain in place because the tenant resource already has view and edit pages. Provider connections remain non-globally-searchable in this slice, and no touched surface may introduce new search-based status leakage.
- **FR-179-017 (Regression coverage is mandatory)**: Regression coverage MUST verify tenant legacy-status suppression, provider primary-status promotion, contradictory multi-status demotion, badge truth alignment, and global-search safety. Final implementation validation MUST verify the absence of schema requirements and new persisted truth.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant list | `app/Filament/Resources/TenantResource.php` and `app/Filament/Resources/TenantResource/Pages/ListTenants.php` | `Add tenant` | `recordUrl()` opens the tenant view; one row-primary action slot is preserved | Primary overflow includes `Resume onboarding` or `View related onboarding`; `More` contains `Open`, `Edit`, `Grant admin consent`, `Open in Entra`, `Sync`, `Verify configuration`, `Restore`, `Force delete`, `Archive`, and RBAC helper actions | `Sync selected` stays grouped under `More` | `Add tenant` | n/a | n/a | existing only | Action Surface Contract remains satisfied. This spec changes status truth, not the action inventory. |
| Tenant view | `app/Filament/Resources/TenantResource/Pages/ViewTenant.php` | `Provider connections`, `Edit`, `View related onboarding`, `Grant admin consent`, `Open in Entra`, `Verify configuration` inside the existing header `ActionGroup` | direct page | n/a | none | n/a | Existing grouped lifecycle and verification actions remain | n/a | existing only | No new header mutations are introduced. The change is in default-visible status semantics only. |
| Provider connections list | `app/Filament/Resources/ProviderConnectionResource.php` and `app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php` | `New connection` | `recordUrl()` opens the provider connection view | `More` contains `Edit`, `Check connection`, `Inventory sync`, `Compliance snapshot`, `Set as default`, `Enable dedicated override`, `Rotate dedicated credential`, `Delete dedicated credential`, `Revert to platform`, `Enable connection`, and `Disable connection` | none | `New connection` | n/a | n/a | existing only | Canonical tenantless route stays `/admin/provider-connections`; this spec changes which status fields are treated as primary truth. |
| Provider connection view | `app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php` | `Grant admin consent`, `Edit`, plus grouped connection-management actions | direct page | n/a | none | n/a | `Check connection`, `View last check run`, and existing grouped dedicated-credential and connection-state actions remain | n/a | existing only | Existing confirmed and audited mutations remain unchanged. This spec only changes which state is read as primary. |
| Provider connection edit | `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` | Existing grouped connection-management actions remain | direct page | n/a | none | n/a | Existing grouped connection-management actions remain | Default save and cancel actions remain | existing only | UX-001 remains satisfied. The edit surface must show consent and verification as current context without letting legacy fields dominate. |
### Key Entities *(include if feature involves data)*
- **Tenant lifecycle**: The tenant's own lifecycle truth, such as onboarding, active, archived, or equivalent states that describe whether the tenant is in the normal lifecycle.
- **Provider consent state**: The stored state that answers whether the provider permission or consent step has been completed.
- **Provider verification state**: The stored state that answers whether the provider connection has been checked and what that check currently proves.
- **Legacy tenant app status**: An older projected tenant-level field that may still exist in storage but is no longer allowed to act as leading operator truth on targeted surfaces.
- **Legacy provider connection status and health**: Older connection-level projections that may remain in storage for internal compatibility but are no longer allowed to compete with consent and verification on targeted surfaces.
- **Bounded provider signal**: A tightly-scoped tenant-level summary derived from current provider truth that may appear on the tenant list only when it can be expressed without inventing a broader readiness model.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-179-001**: In regression coverage, 100% of targeted tenant surfaces stop showing legacy tenant app status as a default-visible primary operator truth.
- **SC-179-002**: In regression coverage, 100% of targeted provider surfaces show consent state and verification state before any legacy provider status or health field.
- **SC-179-003**: In seeded scenarios where a tenant is active but provider verification is unknown, blocked, degraded, or error, 100% of targeted surfaces avoid implying that the tenant is provider-ready by default.
- **SC-179-004**: In seeded scenarios where legacy fields contradict current consent or verification truth, 100% of targeted surfaces render the current truth hierarchy consistently.
- **SC-179-005**: The feature ships without a required schema migration, a new persisted readiness artifact, or a new cross-surface status family.
## Assumptions
- Existing consent and verification fields are sufficiently trustworthy to become the leading provider truth for this cleanup slice.
- Existing verification reports and provider next-step guidance remain the correct supporting diagnostics for provider truth.
- Existing RBAC status surfaces remain valid as their own domain and do not need redesign in this spec beyond clearer separation from provider truth.
- The tenant list may omit a provider signal for some tenants if no bounded, trustworthy summary can be expressed without inventing a readiness model.
## Non-Goals
- Introducing a full tenant readiness assessment
- Adding a new readiness enum or score
- Building cross-dashboard or needs-attention propagation for every provider problem
- Adding operation pre-flight gating or blocking rules beyond the current behavior
- Introducing verification freshness or scheduled re-verification lifecycle rules
- Removing legacy database columns or internal compatibility paths in this slice
## Dependencies
- Existing tenant lifecycle model and tenant operator surfaces
- Existing provider connection model, consent state, verification state, and verification-report semantics
- Existing centralized badge and presentation mappings used on tenant and provider surfaces
- Existing workspace and tenant scoping, capability enforcement, and deny-as-not-found boundaries
- Existing tenant and provider Filament resources, pages, and regression tests covering truth and authorization behavior
## Definition of Done
- Tenant app status no longer appears as leading operator truth on the targeted tenant surfaces.
- Provider legacy status and health no longer compete with consent and verification on the targeted provider surfaces.
- Tenant lifecycle, provider consent, provider verification, and RBAC remain visibly separate where they coexist.
- No targeted tenant or provider surface allows `active`, `connected`, or `consented` to read as equivalent to ready.
- Badge and presentation usage reflects the cleaned truth hierarchy without inventing a new readiness model.
- The change requires no new persistence structure and is covered by targeted regression tests.

View File

@ -0,0 +1,203 @@
# Tasks: Provider Readiness Source-of-Truth Cleanup
**Input**: Design documents from `/specs/179-provider-truth-cleanup/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/provider-truth-cleanup.openapi.yaml`, `quickstart.md`
**Tests**: Required. Write or update Pest coverage before each behavior change and keep Sail-first verification focused.
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently.
## Phase 1: Setup (Shared Regression Scaffolding)
**Purpose**: Create the focused regression entry points for Spec 179 before changing operator-facing surfaces.
- [X] T001 [P] Create the tenant truth-cleanup Pest scaffold in `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`
- [X] T002 [P] Create the provider truth-cleanup Pest scaffold in `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Lock route, scope, and discovery invariants before changing any tenant or provider truth surfaces.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 Preserve canonical provider CTA and deny-as-not-found invariants in `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`, `tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`, and `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`
- [X] T004 [P] Preserve tenant global-search scope and provider-connection global-search exclusion in `tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`, `tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`, and `tests/Feature/Filament/TenantScopingTest.php`
**Checkpoint**: Scope guards and discovery invariants are ready; tenant and provider truth cleanup can now proceed.
---
## Phase 3: User Story 1 - Read truthful tenant surfaces (Priority: P1) 🎯 MVP
**Goal**: Make tenant list and tenant detail lifecycle-led, remove `app_status` as leading truth, and show provider consent and verification separately from lifecycle.
**Independent Test**: Seed tenants whose lifecycle, legacy `app_status`, consent, and verification disagree, then verify tenant list and tenant detail show lifecycle separately and no longer treat `app_status` as current truth.
### Tests for User Story 1
- [X] T005 [P] [US1] Add tenant list truth regression cases in `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` for active-plus-unknown, onboarding-plus-blocked, missing-default-connection, and multi-connection unsafe-summary omission scenarios
- [X] T006 [P] [US1] Rewrite lifecycle-separation expectations in `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` so lifecycle and RBAC remain visible while `app_status` stops acting as primary truth
### Implementation for User Story 1
- [X] T007 [US1] Remove default-visible `app_status` columns and primary `app_status` filter usage from `app/Filament/Resources/TenantResource.php`, and keep any tenant-list provider signal omitted when current provider truth cannot be compressed safely
- [X] T008 [US1] Remove leading `app_status` detail output and repoint `providerConnectionState()` to `consent_status` and `verification_status` in `app/Filament/Resources/TenantResource.php`
- [X] T009 [US1] Rewrite the tenant Provider summary in `resources/views/filament/infolists/entries/provider-connection-state.blade.php` to lead with consent and verification and demote legacy status and health to diagnostics
- [X] T010 [US1] Align verification deep-dive wording with the new tenant summary contract in `app/Filament/Widgets/Tenant/TenantVerificationReport.php` and `resources/views/filament/widgets/tenant/tenant-verification-report.blade.php`
- [X] T011 [US1] Update canonical provider-connections CTA assertions after the tenant detail summary change in `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`
**Checkpoint**: Tenant list and tenant detail now answer lifecycle and provider questions without implying readiness from legacy fields.
---
## Phase 4: User Story 2 - Diagnose provider connections from current axes (Priority: P1)
**Goal**: Make provider connection list, view, and edit surfaces lead with consent and verification while demoting legacy connection status and health to diagnostics.
**Independent Test**: Seed provider connections whose legacy `status` and `health_status` conflict with `consent_status` and `verification_status`, then verify list, view, and edit surfaces elevate the current axes and keep DB-only rendering intact.
### Tests for User Story 2
- [X] T012 [P] [US2] Add provider list, view, and edit truth regression cases in `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` for consent and verification versus legacy status and health conflicts
- [X] T013 [P] [US2] Update provider filter expectations in `tests/Feature/ProviderConnections/RequiredFiltersTest.php` to require consent-led and verification-led filters plus `default_only`
- [X] T014 [P] [US2] Update DB-only rendering expectations in `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php` for the new default-visible provider state columns
### Implementation for User Story 2
- [X] T015 [US2] Promote `consent_status` and `verification_status` to default-visible list columns and demote legacy `status` and `health_status` columns in `app/Filament/Resources/ProviderConnectionResource.php`
- [X] T016 [US2] Replace primary `status` and `health_status` filters with consent-led and verification-led filters and mark any retained legacy filters as diagnostic in `app/Filament/Resources/ProviderConnectionResource.php`
- [X] T017 [US2] Split the provider connection view infolist into Current state and Diagnostics sections in `app/Filament/Resources/ProviderConnectionResource.php`
- [X] T018 [US2] Split the provider connection edit form context into Current state and Diagnostics sections in `app/Filament/Resources/ProviderConnectionResource.php`
**Checkpoint**: Provider connection pages now answer whether a connection is consented and verified before showing any legacy projections.
---
## Phase 5: User Story 3 - Avoid false readiness language across surfaces (Priority: P2)
**Goal**: Keep tenant and provider surfaces from collapsing `active`, `connected`, or `consented` into `ready`, and keep RBAC separate from provider truth.
**Independent Test**: Render tenant and provider surfaces for records that look favorable in one status family but unfavorable in another and verify no default-visible wording, badge, or section title implies readiness.
### Tests for User Story 3
- [X] T019 [P] [US3] Add cross-surface false-readiness assertions in `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` and `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` for active-plus-blocked or error, consented-plus-unknown, and RBAC-separated scenarios
- [X] T020 [P] [US3] Re-run scope-leak and capability regression coverage in `tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`, and `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php` against the cleaned surfaces
### Implementation for User Story 3
- [X] T021 [US3] Normalize operator-facing labels and section headings to Lifecycle, Consent, Verification, and Diagnostics wording in `app/Filament/Resources/TenantResource.php` and `app/Filament/Resources/ProviderConnectionResource.php`
- [X] T022 [US3] Add centralized badge mappings for provider consent and provider verification, keep legacy app-status or connection-status badges diagnostic-only, and avoid any synthetic readiness domain in `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/Domains/ProviderConsentStatusBadge.php`, `app/Support/Badges/Domains/ProviderVerificationStatusBadge.php`, `app/Support/Badges/Domains/TenantAppStatusBadge.php`, `app/Support/Badges/Domains/ProviderConnectionStatusBadge.php`, and `app/Support/Badges/Domains/ProviderConnectionHealthBadge.php`
- [X] T023 [US3] Update unit badge regression coverage for centralized lifecycle, provider consent, provider verification, and legacy diagnostic mappings in `tests/Unit/Badges/TenantBadgesTest.php` and `tests/Unit/Badges/ProviderConnectionBadgesTest.php`
**Checkpoint**: No targeted tenant or provider surface uses favorable legacy language to imply provider readiness.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Format, verify, and manually confirm the cleaned truth hierarchy across all affected surfaces.
- [X] T024 Run `vendor/bin/sail bin pint --dirty --format agent` for touched files under `app/`, `resources/views/`, and `tests/` as governed by `composer.json`
- [X] T025 Run the focused Sail verification pack from `specs/179-provider-truth-cleanup/quickstart.md` against `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`, `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`, `tests/Feature/ProviderConnections/RequiredFiltersTest.php`, `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`, `tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`, `tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`, `tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`, `tests/Feature/Filament/TenantScopingTest.php`, `tests/Unit/Badges/TenantBadgesTest.php`, and `tests/Unit/Badges/ProviderConnectionBadgesTest.php`
- [X] T026 Execute the manual smoke checklist in `specs/179-provider-truth-cleanup/quickstart.md` against `app/Filament/Resources/TenantResource.php` and `app/Filament/Resources/ProviderConnectionResource.php` on `/admin/tenants` and `/admin/provider-connections`
- [X] T027 Validate that the final implementation introduces no schema migration, no new persisted truth, and no unplanned status-family expansion by reviewing `database/migrations/`, `app/Models/Tenant.php`, `app/Models/ProviderConnection.php`, `app/Support/Providers/ProviderConsentStatus.php`, `app/Support/Providers/ProviderVerificationStatus.php`, `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/Domains/ProviderConsentStatusBadge.php`, `app/Support/Badges/Domains/ProviderVerificationStatusBadge.php`, `app/Support/Badges/Domains/TenantAppStatusBadge.php`, `app/Support/Badges/Domains/ProviderConnectionStatusBadge.php`, `app/Support/Badges/Domains/ProviderConnectionHealthBadge.php`, and `specs/179-provider-truth-cleanup/plan.md` against the final diff
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user story work.
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
- **User Story 2 (Phase 4)**: Depends on Foundational completion.
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 completion because it harmonizes cross-surface wording and diagnostic semantics.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **US1**: Independent after Phase 2 and is the recommended MVP slice.
- **US2**: Independent after Phase 2 and can run in parallel with US1.
- **US3**: Depends on the finished tenant and provider surface hierarchy from US1 and US2.
### Within Each User Story
- Write or update the story tests first and confirm they fail for the intended reason.
- Update the primary resource or shared surface contract before adjusting dependent Blade or widget output.
- Finish story-specific assertions after the implementation lands.
- Keep authorization regressions green before advancing to the next story.
### Parallel Opportunities
- `T001` and `T002` can run in parallel.
- `T003` and `T004` can run in parallel.
- `T005` and `T006` can run in parallel.
- `T012`, `T013`, and `T014` can run in parallel.
- `T019` and `T020` can run in parallel.
- Phase 3 and Phase 4 can run in parallel after Phase 2 completes.
---
## Parallel Example: User Story 1
```bash
# Launch the tenant truth regressions together before changing tenant surfaces:
Task: T005 Add tenant list truth regression cases in tests/Feature/Filament/TenantTruthCleanupSpec179Test.php
Task: T006 Rewrite lifecycle-separation expectations in tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php
```
## Parallel Example: User Story 2
```bash
# Launch the provider list and rendering guards together before changing ProviderConnectionResource:
Task: T012 Add provider list, view, and edit truth regression cases in tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php
Task: T013 Update provider filter expectations in tests/Feature/ProviderConnections/RequiredFiltersTest.php
Task: T014 Update DB-only rendering expectations in tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php
```
## Parallel Example: User Story 3
```bash
# Lock the cross-surface wording and scope guards together once US1 and US2 are complete:
Task: T019 Add cross-surface false-readiness assertions in tests/Feature/Filament/TenantTruthCleanupSpec179Test.php and tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php
Task: T020 Re-run scope-leak and capability regression coverage in tests/Feature/Rbac/TenantResourceAuthorizationTest.php, tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php, and tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.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 tenant surfaces with the US1-focused subset of `specs/179-provider-truth-cleanup/quickstart.md`.
5. Demo or review the tenant truth cleanup before expanding to provider surfaces.
### Incremental Delivery
1. Finish Setup and Foundational work.
2. Deliver US1 and validate tenant truth cleanup.
3. Deliver US2 and validate provider truth cleanup.
4. Deliver US3 and validate cross-surface wording and diagnostic consistency.
5. Finish Phase 6 verification and manual smoke checks.
### Parallel Team Strategy
1. One developer completes Phase 1 and Phase 2.
2. After Phase 2, one developer takes US1 while another takes US2.
3. Rejoin on US3 once both surface hierarchies are stable.
4. Finish with shared formatting, focused Sail tests, and manual smoke validation.
---
## 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 task list preserves the plan decision not to invent a new tenant-list readiness badge in this slice.
- No task introduces new persistence, a new readiness enum, or a new presenter layer.

View File

@ -54,6 +54,8 @@
]); ]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$operationsIndexUrl = route('admin.operations.index');
$page = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin')); $page = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));
$page $page
@ -66,7 +68,9 @@
->assertSee(OperationRunLinks::openLabel()) ->assertSee(OperationRunLinks::openLabel())
->assertSee(ViewTenant::verificationHeaderActionLabel()) ->assertSee(ViewTenant::verificationHeaderActionLabel())
->assertDontSee('Start verification') ->assertDontSee('Start verification')
->click(OperationRunLinks::openCollectionLabel()) ->assertScript("Array.from(document.querySelectorAll('a[href=\"{$operationsIndexUrl}\"]')).some((element) => element.textContent?.includes('Open operations'))", true);
visit($operationsIndexUrl)
->assertNoJavaScriptErrors() ->assertNoJavaScriptErrors()
->assertRoute('admin.operations.index'); ->assertRoute('admin.operations.index');
@ -87,7 +91,7 @@
visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin')) visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'))
->assertNoJavaScriptErrors() ->assertNoJavaScriptErrors()
->assertSee('Verification report') ->assertSee('Verification report')
->assertSee('No verification operation has been started yet.') ->assertSee('No provider verification check has been recorded yet.')
->assertSee('Start verification') ->assertSee('Start verification')
->assertDontSee(OperationRunLinks::openLabel()); ->assertDontSee(OperationRunLinks::openLabel());
}); });

View File

@ -0,0 +1,273 @@
<?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\OperationRunLinks;
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);
$coverageUrl = InventoryCoverage::getUrl(tenant: $tenant);
$basisRunUrl = OperationRunLinks::view($run, $tenant);
$inventoryItemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant);
$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($coverageUrl);
$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)
->assertScript("Array.from(document.querySelectorAll('a[href=\"{$basisRunUrl}\"]')).some((element) => element.textContent?.includes('Open basis run'))", true);
visit($basisRunUrl)
->waitForText('Operation #'.(int) $run->getKey())
->assertNoJavaScriptErrors()
->assertRoute('admin.operations.view', ['run' => (int) $run->getKey()])
->assertSee('Inventory sync coverage')
->assertSee('Need follow-up');
visit($coverageUrl)
->waitForText('Tenant coverage truth')
->assertNoJavaScriptErrors()
->assertScript("Array.from(document.querySelectorAll('a[href=\"{$inventoryItemsUrl}\"]')).some((element) => element.textContent?.includes('Open inventory items'))", true);
visit($inventoryItemsUrl)
->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

@ -130,10 +130,23 @@ function dashboardKpiStatPayloads($component): array
'description' => 'healthy queued or running tenant work', 'description' => 'healthy queued or running tenant work',
'url' => OperationRunLinks::index($tenant, activeTab: 'active'), 'url' => OperationRunLinks::index($tenant, activeTab: 'active'),
], ],
'Operations needing follow-up' => [ 'Likely stale operations' => [
'value' => '3', 'value' => '1',
'description' => 'failed, warning, or stalled runs', 'description' => 'queued or running past the lifecycle window',
'url' => OperationRunLinks::index($tenant, activeTab: 'blocked'), 'url' => OperationRunLinks::index(
$tenant,
activeTab: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
problemClass: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
),
],
'Terminal follow-up operations' => [
'value' => '2',
'description' => 'blocked, partial, failed, or auto-reconciled runs',
'url' => OperationRunLinks::index(
$tenant,
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
),
], ],
]); ]);
}); });

View File

@ -3,7 +3,11 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\InventoryCoverage;
use App\Models\OperationRun;
use App\Models\Tenant; 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 App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -17,6 +21,45 @@
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]); $tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); 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); $this->actingAs($user);
Filament::setCurrentPanel('admin'); Filament::setCurrentPanel('admin');
Filament::setTenant(null, true); Filament::setTenant(null, true);
@ -29,5 +72,10 @@
Livewire::actingAs($user)->test(InventoryCoverage::class) Livewire::actingAs($user)->test(InventoryCoverage::class)
->assertOk() ->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); declare(strict_types=1);
use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\InventoryCoverage;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\TagBadgeCatalog; use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use App\Support\Badges\TagBadgeDomain;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Tables\Columns\IconColumn; use Illuminate\Foundation\Testing\RefreshDatabase;
use Filament\Tables\Columns\TextColumn;
use Livewire\Features\SupportTesting\Testable; use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire; use Livewire\Livewire;
uses(RefreshDatabase::class);
function inventoryCoverageRecordKey(string $segment, string $type): string function inventoryCoverageRecordKey(string $segment, string $type): string
{ {
return "{$segment}:{$type}"; return "{$segment}:{$type}";
@ -31,198 +33,147 @@ function inventoryCoverageComponent(User $user, Tenant $tenant): Testable
return Livewire::actingAs($user)->test(InventoryCoverage::class); return Livewire::actingAs($user)->test(InventoryCoverage::class);
} }
function removeInventoryCoverageRestoreMetadata(): void function seedTruthfulCoverageRun(Tenant $tenant): OperationRun
{ {
config()->set( InventoryItem::factory()->create([
'tenantpilot.supported_policy_types', 'tenant_id' => (int) $tenant->getKey(),
collect(config('tenantpilot.supported_policy_types', [])) 'display_name' => 'Conditional Access Prod',
->map(function (array $row): array { 'policy_type' => 'conditionalAccessPolicy',
unset($row['restore']); 'external_id' => 'ca-1',
'platform' => 'windows',
]);
return $row; InventoryItem::factory()->create([
}) 'tenant_id' => (int) $tenant->getKey(),
->all(), 'display_name' => 'Compliance Legacy',
); 'policy_type' => 'deviceCompliancePolicy',
'external_id' => 'dc-1',
'platform' => 'windows',
]);
config()->set( return OperationRun::factory()->create([
'tenantpilot.foundation_types', 'tenant_id' => (int) $tenant->getKey(),
collect(config('tenantpilot.foundation_types', [])) 'workspace_id' => (int) $tenant->workspace_id,
->map(function (array $row): array { 'type' => 'inventory_sync',
unset($row['restore']); 'status' => 'completed',
'outcome' => 'partially_succeeded',
return $row; 'context' => [
}) 'inventory' => [
->all(), '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'); [$user, $tenant] = createUserWithTenant(role: 'owner');
seedTruthfulCoverageRun($tenant);
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy'); $failedKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
$deviceConfigurationKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration'); $unknownKey = inventoryCoverageRecordKey('policy', 'deviceCompliancePolicy');
$scopeTagKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag'); $skippedKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
$succeededKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
inventoryCoverageComponent($user, $tenant) inventoryCoverageComponent($user, $tenant)
->assertOk() ->assertOk()
->assertTableColumnExists('type') ->assertTableColumnExists('coverage_state')
->assertTableColumnExists('label') ->assertTableColumnExists('label')
->assertTableColumnExists('follow_up_guidance')
->assertTableColumnExists('observed_item_count')
->assertTableColumnExists('category') ->assertTableColumnExists('category')
->assertTableColumnExists('dependencies') ->assertCanSeeTableRecords([$failedKey, $unknownKey, $skippedKey], inOrder: true)
->assertCountTableRecords( ->assertTableColumnFormattedStateSet(
count(config('tenantpilot.supported_policy_types', [])) + count(config('tenantpilot.foundation_types', [])), 'coverage_state',
BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label,
$failedKey,
) )
->assertCanSeeTableRecords([$conditionalAccessKey, $scopeTagKey]) ->assertTableColumnFormattedStateSet(
->searchTable('conditional') 'coverage_state',
->assertCanSeeTableRecords([$conditionalAccessKey]) BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'unknown')->label,
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $scopeTagKey]) $unknownKey,
->searchTable('Scope Tag') )
->assertCanSeeTableRecords([$scopeTagKey]) ->assertTableColumnFormattedStateSet(
->assertCanNotSeeTableRecords([$conditionalAccessKey]) 'coverage_state',
->searchTable(null) BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'skipped')->label,
->assertCanSeeTableRecords([$conditionalAccessKey, $deviceConfigurationKey, $scopeTagKey]); $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'); [$user, $tenant] = createUserWithTenant(role: 'owner');
seedTruthfulCoverageRun($tenant);
$appProtectionKey = inventoryCoverageRecordKey('policy', 'appProtectionPolicy'); $failedKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy'); $unknownKey = inventoryCoverageRecordKey('policy', 'deviceCompliancePolicy');
$deviceComplianceKey = inventoryCoverageRecordKey('policy', 'deviceCompliancePolicy'); $skippedKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
$adminTemplatesKey = inventoryCoverageRecordKey('policy', 'groupPolicyConfiguration'); $succeededKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
$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');
inventoryCoverageComponent($user, $tenant) inventoryCoverageComponent($user, $tenant)
->assertTableFilterExists('coverage_state')
->assertTableFilterExists('category') ->assertTableFilterExists('category')
->assertTableFilterExists('restore') ->assertTableFilterExists('restore')
->filterTable('category', 'Foundations') ->filterTable('coverage_state', 'failed')
->assertCanSeeTableRecords([$assignmentFilterKey, $scopeTagKey]) ->assertCanSeeTableRecords([$failedKey])
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $conditionalAccessKey, $roleDefinitionKey, $roleAssignmentKey]) ->assertCanNotSeeTableRecords([$unknownKey, $skippedKey, $succeededKey])
->removeTableFilters() ->removeTableFilters()
->filterTable('category', 'RBAC') ->filterTable('category', 'Foundations')
->assertCanSeeTableRecords([$roleDefinitionKey, $roleAssignmentKey]) ->assertCanSeeTableRecords([$skippedKey])
->assertCanNotSeeTableRecords([$assignmentFilterKey, $scopeTagKey, $deviceConfigurationKey]) ->assertCanNotSeeTableRecords([$failedKey, $unknownKey, $succeededKey])
->removeTableFilters() ->removeTableFilters()
->filterTable('restore', 'preview-only') ->filterTable('restore', 'preview-only')
->assertCanSeeTableRecords([$conditionalAccessKey, $securityBaselineKey, $roleDefinitionKey, $roleAssignmentKey]) ->assertCanSeeTableRecords([$succeededKey])
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $assignmentFilterKey]); ->assertCanNotSeeTableRecords([$failedKey, $skippedKey]);
}); });
it('omits the restore filter when the runtime dataset has no restore metadata', function (): void { it('shows a clear-filters empty state for the derived truth table', function (): void {
removeInventoryCoverageRestoreMetadata();
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
seedTruthfulCoverageRun($tenant);
$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');
inventoryCoverageComponent($user, $tenant) inventoryCoverageComponent($user, $tenant)
->assertTableEmptyStateActionsExistInOrder(['clear_filters']) ->assertTableEmptyStateActionsExistInOrder(['clear_filters'])
->searchTable('no-such-coverage-entry') ->searchTable('no-such-coverage-entry')
->assertCountTableRecords(0) ->assertCountTableRecords(0)
->assertSee('No coverage entries match this view') ->assertSee('No coverage rows match this report')
->assertSee('Clear filters') ->assertSee('Clear filters');
->searchTable(null)
->assertCountTableRecords(
count(config('tenantpilot.supported_policy_types', [])) + count(config('tenantpilot.foundation_types', [])),
)
->assertCanSeeTableRecords([$conditionalAccessKey]);
}); });
it('preserves badge semantics and dependency indicators in the interactive table columns', function (): void { it('returns 404 for non-members on the inventory coverage page even when basis truth exists', 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 {
[$owner, $tenant] = createUserWithTenant(role: 'owner'); [$owner, $tenant] = createUserWithTenant(role: 'owner');
seedTruthfulCoverageRun($tenant);
$this->actingAs($owner); $this->actingAs($owner);
$tenant->makeCurrent(); $tenant->makeCurrent();

View File

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

View File

@ -3,8 +3,10 @@
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems; use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Auth\UiTooltips; use App\Support\Auth\UiTooltips;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Livewire\Livewire; use Livewire\Livewire;
@ -68,3 +70,50 @@
->assertActionDisabled('run_inventory_sync') ->assertActionDisabled('run_inventory_sync')
->assertActionExists('run_inventory_sync', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission()); ->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 <?php
declare(strict_types=1);
use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\InventoryCoverage;
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; 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); uses(RefreshDatabase::class);
test('inventory hub pages load for a tenant', function () {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
function seedInventoryCoverageBasis(Tenant $tenant): OperationRun
{
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Item A', 'display_name' => 'Conditional Access Prod',
'policy_type' => 'deviceConfiguration', 'policy_type' => 'conditionalAccessPolicy',
'external_id' => 'item-a', 'external_id' => 'ca-1',
'platform' => 'windows', 'platform' => 'windows',
]); ]);
OperationRun::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => $tenant->workspace_id, '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', 'type' => 'inventory_sync',
'status' => 'completed', 'status' => 'completed',
'outcome' => 'succeeded', 'outcome' => 'partially_succeeded',
'context' => ['selection_hash' => str_repeat('a', 64)], '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(), '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); $itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant);
$coverageUrl = InventoryCoverage::getUrl(tenant: $tenant); $coverageUrl = InventoryCoverage::getUrl(tenant: $tenant);
$kpiLabels = [
'Total items',
'Coverage',
'Last inventory sync',
'Active ops',
'Inventory ops',
];
$this->actingAs($user) $this->actingAs($user)
->get($itemsUrl) ->get($itemsUrl)
->assertOk() ->assertOk()
->assertSee('Run Inventory Sync') ->assertSee('Run Inventory Sync')
->assertSee($kpiLabels) ->assertSee('Total items')
->assertSee('Item A'); ->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) $this->actingAs($user)
->get($coverageUrl) ->get($coverageUrl)
->assertOk() ->assertOk()
->assertSee($kpiLabels) ->assertSee('Tenant coverage truth')
->assertSee('Coverage') ->assertSee('Covered types')
->assertSee('Searchable support matrix') ->assertSee('Need follow-up')
->assertSee('Search by policy type or label') ->assertSee('Observed items')
->assertSee('Coverage rows') ->assertSee('Inventory sync history')
->assertSee('Segment') ->assertSee('Open inventory items')
->assertSee('Dependencies'); ->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

@ -284,3 +284,35 @@ function createNeedsAttentionTenant(): array
->not->toContain(FindingResource::getUrl('index', ['tab' => 'overdue'], panel: 'tenant', tenant: $tenant)) ->not->toContain(FindingResource::getUrl('index', ['tab' => 'overdue'], panel: 'tenant', tenant: $tenant))
->toContain('Open Baseline Compare'); ->toContain('Open Baseline Compare');
}); });
it('separates stale active attention from terminal follow-up on tenant operations attention', function (): void {
[$user, $tenant] = createNeedsAttentionTenant();
$this->actingAs($user);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subHour(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class)
->assertSee('Active operations look stale')
->assertSee('Terminal operations need follow-up')
->assertSee('Open stale operations')
->assertSee('Open terminal follow-up')
->assertDontSee('Current governance and findings signals look trustworthy.');
});

View File

@ -8,6 +8,7 @@
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
@ -475,3 +476,58 @@ function baselineCompareGapContext(array $overrides = []): array
->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) ->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound(); ->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

@ -52,11 +52,20 @@
->set('tableFilters.default_only.isActive', true); ->set('tableFilters.default_only.isActive', true);
$table = $component->instance()->getTable(); $table = $component->instance()->getTable();
$visibleColumnNames = collect($table->getVisibleColumns())
->map(fn ($column): string => $column->getName())
->values()
->all();
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource()); expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found'); expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found');
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue(); expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('display_name')?->isSortable())->toBeTrue(); expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
expect($visibleColumnNames)->toContain('consent_status', 'verification_status');
expect($visibleColumnNames)->not->toContain('status');
expect($visibleColumnNames)->not->toContain('health_status');
expect($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('health_status')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('migration_review_required'))->not->toBeNull(); expect($table->getColumn('migration_review_required'))->not->toBeNull();

View File

@ -2,9 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Widgets\Dashboard\RecentOperations;
use App\Filament\Widgets\Tenant\RecentOperationsSummary; use App\Filament\Widgets\Tenant\RecentOperationsSummary;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Filament\Facades\Filament;
use Livewire\Livewire; use Livewire\Livewire;
it('renders recent operations from the record tenant in admin panel context', function (): void { it('renders recent operations from the record tenant in admin panel context', function (): void {
@ -31,3 +35,54 @@
->assertSee('No action needed.') ->assertSee('No action needed.')
->assertDontSee('No operations yet.'); ->assertDontSee('No operations yet.');
}); });
it('renders stale-active and reconciled terminal truth on tenant recent-operations surfaces', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subHour(),
]);
OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'restore.execute',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'context' => [
'reconciliation' => [
'reconciled_at' => now()->toIso8601String(),
'reason' => 'run.infrastructure_timeout_or_abandonment',
'reason_code' => 'run.infrastructure_timeout_or_abandonment',
'source' => 'failed_callback',
],
],
'failure_summary' => [[
'code' => 'operation.failed',
'reason_code' => 'run.infrastructure_timeout_or_abandonment',
'message' => 'Infrastructure ended the run before completion.',
]],
]);
Livewire::actingAs($user)
->test(RecentOperationsSummary::class, ['record' => $tenant])
->assertSee('Likely stale')
->assertSee('Automatically reconciled');
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(RecentOperations::class)
->assertSee('Likely stale')
->assertSee('Automatically reconciled')
->assertSee('Review worker health and logs before retrying from the start surface.')
->assertSee('Review worker health and logs before retrying this operation.');
});

View File

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

View File

@ -134,11 +134,11 @@ function spec125BaselineTenantContext(): array
$table = spec125BaselineTable($component); $table = spec125BaselineTable($component);
expect($table->getDefaultSortColumn())->toBe('label'); expect($table->getDefaultSortColumn())->toBe('follow_up_priority');
expect($table->getDefaultSortDirection())->toBe('asc'); expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::customPage()); expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::customPage());
expect($table->getEmptyStateHeading())->toBe('No coverage entries match this view'); 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 coverage matrix.'); 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('type')?->isSortable())->toBeTrue();
expect($table->getColumn('label')?->isSortable())->toBeTrue(); expect($table->getColumn('label')?->isSortable())->toBeTrue();
expect($table->getColumn('dependencies')?->isToggleable())->toBeTrue(); expect($table->getColumn('dependencies')?->isToggleable())->toBeTrue();

View File

@ -69,7 +69,7 @@ function seedTrustworthyCompare(array $tenantContext): void
]); ]);
} }
it('suppresses calm dashboard wording when operations follow-up still exists', function (): void { it('suppresses calm dashboard wording when stale and terminal operations both need attention', function (): void {
$tenantContext = createTruthAlignedDashboardTenant(); $tenantContext = createTruthAlignedDashboardTenant();
[$user, $tenant] = $tenantContext; [$user, $tenant] = $tenantContext;
$this->actingAs($user); $this->actingAs($user);
@ -85,12 +85,22 @@ function seedTrustworthyCompare(array $tenantContext): void
'created_at' => now()->subHour(), 'created_at' => now()->subHour(),
]); ]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
Filament::setCurrentPanel(Filament::getPanel('tenant')); Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class) Livewire::test(NeedsAttention::class)
->assertSee('Operations need follow-up') ->assertSee('Active operations look stale')
->assertSee('Open operations') ->assertSee('Terminal operations need follow-up')
->assertSee('Open stale operations')
->assertSee('Open terminal follow-up')
->assertDontSee('Current governance and findings signals look trustworthy.'); ->assertDontSee('Current governance and findings signals look trustworthy.');
Livewire::test(BaselineCompareNow::class) Livewire::test(BaselineCompareNow::class)
@ -151,7 +161,8 @@ function seedTrustworthyCompare(array $tenantContext): void
Livewire::test(NeedsAttention::class) Livewire::test(NeedsAttention::class)
->assertSee('Current governance and findings signals look trustworthy.') ->assertSee('Current governance and findings signals look trustworthy.')
->assertSee('Operations are active') ->assertSee('Operations are active')
->assertDontSee('Operations need follow-up'); ->assertDontSee('Terminal operations need follow-up')
->assertDontSee('Active operations look stale');
Livewire::test(BaselineCompareNow::class) Livewire::test(BaselineCompareNow::class)
->assertSee('Aligned') ->assertSee('Aligned')

View File

@ -58,6 +58,8 @@ function tenantSearchTitles($results): array
expect($results->first()?->url) expect($results->first()?->url)
->not->toBeNull(); ->not->toBeNull();
expect(collect($results)->filter(fn ($result): bool => filled($result->url))->count())
->toBe($results->count());
}); });
it('keeps first-slice taxonomy resources out of global search', function (): void { it('keeps first-slice taxonomy resources out of global search', function (): void {

View File

@ -66,7 +66,8 @@
->assertSee('Lifecycle summary') ->assertSee('Lifecycle summary')
->assertSee('This tenant is active and available across normal management, tenant selection, and operational follow-up flows.') ->assertSee('This tenant is active and available across normal management, tenant selection, and operational follow-up flows.')
->assertSee('RBAC status') ->assertSee('RBAC status')
->assertSee('App status'); ->assertDontSee('App status')
->assertSee('Provider connection');
}); });
it('renders the archived banner from the shared lifecycle presentation contract', function (): void { it('renders the archived banner from the shared lifecycle presentation contract', function (): void {

View File

@ -14,7 +14,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('keeps lifecycle, app status, and rbac status separated on the tenant view page', function (): void { it('keeps lifecycle and rbac status separated while removing app status from the tenant view page', function (): void {
[$user, $tenant] = createUserWithTenant( [$user, $tenant] = createUserWithTenant(
tenant: Tenant::factory()->create([ tenant: Tenant::factory()->create([
'status' => Tenant::STATUS_ONBOARDING, 'status' => Tenant::STATUS_ONBOARDING,
@ -32,8 +32,7 @@
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Lifecycle summary') ->assertSee('Lifecycle summary')
->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.') ->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.')
->assertSee('App status') ->assertDontSee('App status')
->assertSee('Consent required')
->assertSee('RBAC status') ->assertSee('RBAC status')
->assertSee('Failed'); ->assertSee('Failed');
}); });

View File

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('removes tenant app status from tenant list primary truth and filters', function (): void {
$tenant = Tenant::factory()->active()->create([
'name' => 'Primary Truth Tenant',
'app_status' => 'ok',
]);
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'display_name' => 'Primary Truth Connection',
'is_default' => true,
'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Unknown->value,
'status' => 'connected',
'health_status' => 'ok',
]);
$this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Filament::setTenant(null, true);
$component = Livewire::actingAs($user)
->test(ListTenants::class)
->assertCanSeeTableRecords([$tenant]);
$table = $component->instance()->getTable();
$filterNames = array_keys($table->getFilters());
$visibleColumnNames = collect($table->getVisibleColumns())
->map(fn ($column): string => $column->getName())
->values()
->all();
expect($filterNames)->not->toContain('app_status')
->and($visibleColumnNames)->not->toContain('app_status')
->and($visibleColumnNames)->not->toContain('consent_status')
->and($visibleColumnNames)->not->toContain('verification_status')
->and($visibleColumnNames)->not->toContain('provider_connection_state');
});
it('keeps lifecycle and rbac separate while leading the provider summary with consent and verification', function (): void {
$tenant = Tenant::factory()->create([
'status' => Tenant::STATUS_ONBOARDING,
'app_status' => 'consent_required',
'rbac_status' => 'failed',
'name' => 'Truth Cleanup Tenant',
]);
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'display_name' => 'Truth Cleanup Connection',
'is_default' => true,
'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Blocked->value,
'status' => 'connected',
'health_status' => 'ok',
]);
$this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Filament::setTenant(null, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Lifecycle summary')
->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.')
->assertSee('RBAC status')
->assertSee('Failed')
->assertDontSee('App status')
->assertSee('Truth Cleanup Connection')
->assertSee('Granted')
->assertSee('Blocked')
->assertSee('Legacy status')
->assertSee('Connected')
->assertSee('Legacy health')
->assertSee('OK');
});
it('flags tenants that have microsoft connections but no default connection configured', function (): void {
$tenant = Tenant::factory()->active()->create([
'name' => 'Missing Default Tenant',
]);
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'display_name' => 'Fallback Microsoft Connection',
'is_default' => false,
'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Healthy->value,
'status' => 'connected',
'health_status' => 'ok',
]);
$this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Filament::setTenant(null, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Needs action: set a default Microsoft provider connection.')
->assertSee('Fallback Microsoft Connection')
->assertSee('Open Provider Connections');
});
it('does not collapse active lifecycle and blocked provider verification into readiness language', function (): void {
$tenant = Tenant::factory()->active()->create([
'name' => 'No False Readiness Tenant',
'app_status' => 'ok',
'rbac_status' => 'configured',
]);
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'display_name' => 'Blocked Connection',
'is_default' => true,
'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Blocked->value,
'status' => 'connected',
'health_status' => 'ok',
]);
$this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Filament::setTenant(null, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Active')
->assertSee('Granted')
->assertSee('Blocked')
->assertSee('RBAC status')
->assertDontSee('Ready');
});

View File

@ -201,7 +201,7 @@
]); ]);
Livewire::test(TenantVerificationReport::class, ['record' => $tenant]) Livewire::test(TenantVerificationReport::class, ['record' => $tenant])
->assertSee('No verification operation has been started yet.') ->assertSee('No provider verification check has been recorded yet.')
->call('startVerification'); ->call('startVerification');
$run = OperationRun::query() $run = OperationRun::query()
@ -234,7 +234,7 @@
Livewire::actingAs($user) Livewire::actingAs($user)
->test(TenantVerificationReport::class, ['record' => $tenant]) ->test(TenantVerificationReport::class, ['record' => $tenant])
->assertSee('No verification operation has been started yet.') ->assertSee('No provider verification check has been recorded yet.')
->assertSee('Verification can be started from tenant management only while the tenant is active.') ->assertSee('Verification can be started from tenant management only while the tenant is active.')
->assertDontSee('Start verification'); ->assertDontSee('Start verification');
}); });

View File

@ -41,7 +41,7 @@
->and($overview['calmness']['is_calm'])->toBeFalse() ->and($overview['calmness']['is_calm'])->toBeFalse()
->and($overview['calmness']['next_action']['kind'])->toBe('operations_index') ->and($overview['calmness']['next_action']['kind'])->toBe('operations_index')
->and($overview['calmness']['next_action']['url'])->toContain('tenant_scope=all') ->and($overview['calmness']['next_action']['url'])->toContain('tenant_scope=all')
->and($overview['calmness']['next_action']['url'])->toContain('activeTab=blocked'); ->and($overview['calmness']['next_action']['url'])->toContain('activeTab=active');
}); });
it('uses switch workspace as the zero-tenant recovery action', function (): void { it('uses switch workspace as the zero-tenant recovery action', function (): void {

View File

@ -96,9 +96,10 @@
->and($items->get('tenant_overdue_findings')['destination']['kind'])->toBe('tenant_findings') ->and($items->get('tenant_overdue_findings')['destination']['kind'])->toBe('tenant_findings')
->and($items->get('tenant_overdue_findings')['destination']['url'])->toContain('tab=overdue') ->and($items->get('tenant_overdue_findings')['destination']['url'])->toContain('tab=overdue')
->and($items->get('tenant_compare_attention')['destination']['kind'])->toBe('baseline_compare_landing') ->and($items->get('tenant_compare_attention')['destination']['kind'])->toBe('baseline_compare_landing')
->and($items->get('tenant_operations_follow_up')['destination']['kind'])->toBe('operations_index') ->and($items->get('tenant_operations_terminal_follow_up')['destination']['kind'])->toBe('operations_index')
->and($items->get('tenant_operations_follow_up')['destination']['url'])->toContain('activeTab=blocked') ->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('activeTab=terminal_follow_up')
->and($items->get('tenant_operations_follow_up')['destination']['url'])->toContain('tenant_id='.(string) $tenantOperations->getKey()) ->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('problemClass=terminal_follow_up')
->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('tenant_id='.(string) $tenantOperations->getKey())
->and($items->get('tenant_alert_delivery_failures')['destination']['kind'])->toBe('alerts_overview') ->and($items->get('tenant_alert_delivery_failures')['destination']['kind'])->toBe('alerts_overview')
->and($items->get('tenant_alert_delivery_failures')['destination']['url'])->toContain('nav%5Bback_url%5D='); ->and($items->get('tenant_alert_delivery_failures')['destination']['url'])->toContain('nav%5Bback_url%5D=');
}); });

View File

@ -52,6 +52,29 @@
'type' => 'inventory_sync', 'type' => 'inventory_sync',
'status' => \App\Support\OperationRunStatus::Running->value, 'status' => \App\Support\OperationRunStatus::Running->value,
'outcome' => \App\Support\OperationRunOutcome::Pending->value, 'outcome' => \App\Support\OperationRunOutcome::Pending->value,
'created_at' => now()->subHour(),
'started_at' => now()->subHour(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'restore.execute',
'status' => \App\Support\OperationRunStatus::Completed->value,
'outcome' => \App\Support\OperationRunOutcome::Failed->value,
'context' => [
'reconciliation' => [
'reconciled_at' => now()->toIso8601String(),
'reason' => 'run.infrastructure_timeout_or_abandonment',
'reason_code' => 'run.infrastructure_timeout_or_abandonment',
'source' => 'failed_callback',
],
],
'failure_summary' => [[
'code' => 'operation.failed',
'reason_code' => 'run.infrastructure_timeout_or_abandonment',
'message' => 'Infrastructure ended the run before completion.',
]],
]); ]);
$this->actingAs($user) $this->actingAs($user)
@ -59,5 +82,7 @@
->get('/admin') ->get('/admin')
->assertOk() ->assertOk()
->assertSee('Diagnostic recency across your visible workspace slice. This does not define governance health on its own.') ->assertSee('Diagnostic recency across your visible workspace slice. This does not define governance health on its own.')
->assertSee('Likely stale')
->assertSee('Automatically reconciled')
->assertDontSee('Visible governance, findings, compare posture, and activity currently look calm.'); ->assertDontSee('Visible governance, findings, compare posture, and activity currently look calm.');
}); });

View File

@ -319,6 +319,8 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array
expect($coverage['intuneRoleDefinition']['status'] ?? null)->toBe('succeeded'); expect($coverage['intuneRoleDefinition']['status'] ?? null)->toBe('succeeded');
expect($coverage['intuneRoleAssignment']['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 () { 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))) : []; $policyTypes = is_array($policyTypes) ? array_values(array_filter(array_map('strval', $policyTypes))) : [];
foreach ($policyTypes as $policyType) { foreach ($policyTypes as $policyType) {
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null); $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null, 1);
} }
return [ return [
@ -101,4 +101,6 @@
expect($coverage)->toBeArray(); expect($coverage)->toBeArray();
expect(array_keys($coverage))->toEqualCanonicalizing($policyTypes); 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 $mockSync
->shouldReceive('executeSelection') ->shouldReceive('executeSelection')
->once() ->once()
->andReturn([ ->andReturnUsing(function (OperationRun $operationRun, $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed) use ($computed): array {
'status' => 'success', foreach ($computed['selection']['policy_types'] as $policyType) {
'had_errors' => false, $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null, 1);
'error_codes' => [], }
'error_context' => [],
'errors_count' => 0, return [
'items_observed_count' => 0, 'status' => 'success',
'items_upserted_count' => 0, 'had_errors' => false,
'skipped_policy_types' => [], 'error_codes' => [],
'processed_policy_types' => $computed['selection']['policy_types'], 'error_context' => [],
'failed_policy_types' => [], 'errors_count' => 0,
'selection_hash' => $computed['selection_hash'], '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 */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -65,6 +71,7 @@
$context = is_array($opRun->context) ? $opRun->context : []; $context = is_array($opRun->context) ? $opRun->context : [];
expect($context)->toHaveKey('result'); expect($context)->toHaveKey('result');
expect($context['result']['had_errors'] ?? null)->toBeFalse(); 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 : []; $counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : [];
expect((int) ($counts['total'] ?? 0))->toBe(count($policyTypes)); expect((int) ($counts['total'] ?? 0))->toBe(count($policyTypes));
@ -133,6 +140,7 @@
$context = is_array($opRun->context) ? $opRun->context : []; $context = is_array($opRun->context) ? $opRun->context : [];
expect($context)->toHaveKey('result'); expect($context)->toHaveKey('result');
expect($context['result']['had_errors'] ?? null)->toBeTrue(); 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 : []; $counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : [];
expect((int) ($counts['processed'] ?? 0))->toBe(count($policyTypes)); expect((int) ($counts['processed'] ?? 0))->toBe(count($policyTypes));

View File

@ -87,8 +87,8 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations') ->get('/admin/operations')
->assertOk() ->assertOk()
->assertSee('active operation(s) are beyond their lifecycle window') ->assertSee('active operation(s) are beyond their lifecycle window and belong in the stale-attention view')
->assertSee('operation(s) have already been automatically reconciled'); ->assertSee('operation(s) already carry reconciled stale lineage and belong in terminal follow-up');
}); });
it('renders completed operation rows without leaking array-state unknown badges', function (): void { it('renders completed operation rows without leaking array-state unknown badges', function (): void {

View File

@ -39,6 +39,6 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations') ->get('/admin/operations')
->assertOk() ->assertOk()
->assertSee('1 active operation(s) are beyond their lifecycle window.') ->assertSee('1 active operation(s) are beyond their lifecycle window and belong in the stale-attention view.')
->assertSee('1 operation(s) have already been automatically reconciled.'); ->assertSee('1 operation(s) already carry reconciled stale lineage and belong in terminal follow-up.');
}); });

View File

@ -45,13 +45,14 @@
->get('/admin/operations') ->get('/admin/operations')
->assertOk() ->assertOk()
->assertSee('Likely stale') ->assertSee('Likely stale')
->assertSee('operation(s) have already been automatically reconciled'); ->assertSee('belong in terminal follow-up');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $reconciledRun->getKey()])) ->get(route('admin.operations.view', ['run' => (int) $reconciledRun->getKey()]))
->assertOk() ->assertOk()
->assertSee('Automatically reconciled'); ->assertSee('Automatically reconciled')
->assertSee('Still active: No. Automatic reconciliation: Yes.');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])

Some files were not shown because too many files have changed in this diff Show More