feat: align tenant dashboard truth surfaces #204

Merged
ahmido merged 1 commits from 173-tenant-dashboard-truth-alignment into dev 2026-04-03 20:26:16 +00:00
30 changed files with 3163 additions and 123 deletions

View File

@ -123,6 +123,8 @@ ## Active Technologies
- PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest` (171-operations-naming-consolidation) - PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest` (171-operations-naming-consolidation)
- PostgreSQL with existing `operation_runs`, notification payloads, workspace records, and tenant records; no schema changes (171-operations-naming-consolidation) - PostgreSQL with existing `operation_runs`, notification payloads, workspace records, and tenant records; no schema changes (171-operations-naming-consolidation)
- PostgreSQL with existing `operation_runs`, `managed_tenant_onboarding_sessions`, tenant records, and workspace records; no schema changes (172-deferred-operator-surfaces-retrofit) - PostgreSQL with existing `operation_runs`, `managed_tenant_onboarding_sessions`, tenant records, and workspace records; no schema changes (172-deferred-operator-surfaces-retrofit)
- 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 (173-tenant-dashboard-truth-alignment)
- PostgreSQL unchanged; no new persistence, cache store, or durable dashboard summary artifac (173-tenant-dashboard-truth-alignment)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -142,8 +144,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 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
- 172-deferred-operator-surfaces-retrofit: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest` - 172-deferred-operator-surfaces-retrofit: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest`
- 171-operations-naming-consolidation: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest` - 171-operations-naming-consolidation: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest`
- 170-system-operations-surface-alignment: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest`
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -81,6 +81,7 @@ public function mount(): void
); );
$this->mountInteractsWithTable(); $this->mountInteractsWithTable();
$this->applyRequestedDashboardPrefilter();
} }
protected function getHeaderWidgets(): array protected function getHeaderWidgets(): array
@ -206,40 +207,7 @@ public function lifecycleVisibilitySummary(): array
$policy = app(OperationLifecyclePolicy::class); $policy = app(OperationLifecyclePolicy::class);
$likelyStale = (clone $baseQuery) $likelyStale = (clone $baseQuery)
->whereIn('status', [ ->likelyStale($policy)
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
])
->where(function (Builder $query) use ($policy): void {
foreach ($policy->coveredTypeNames() as $type) {
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
$typeQuery
->where('type', $type)
->where(function (Builder $stateQuery) use ($policy, $type): void {
$stateQuery
->where(function (Builder $queuedQuery) use ($policy, $type): void {
$queuedQuery
->where('status', OperationRunStatus::Queued->value)
->whereNull('started_at')
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
})
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
$runningQuery
->where('status', OperationRunStatus::Running->value)
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
$startedAtQuery
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
$fallbackQuery
->whereNull('started_at')
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
});
});
});
});
});
}
})
->count(); ->count();
return [ return [
@ -251,13 +219,8 @@ public function lifecycleVisibilitySummary(): array
private function applyActiveTab(Builder $query): Builder private function applyActiveTab(Builder $query): Builder
{ {
return match ($this->activeTab) { return match ($this->activeTab) {
'active' => $query->whereIn('status', [ 'active' => $query->healthyActive(),
OperationRunStatus::Queued->value, 'blocked' => $query->dashboardNeedsFollowUp(),
OperationRunStatus::Running->value,
]),
'blocked' => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Blocked->value),
'succeeded' => $query 'succeeded' => $query
->where('status', OperationRunStatus::Completed->value) ->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Succeeded->value), ->where('outcome', OperationRunOutcome::Succeeded->value),
@ -292,4 +255,21 @@ private function scopedSummaryQuery(): ?Builder
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter), fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
); );
} }
private function applyRequestedDashboardPrefilter(): void
{
$requestedTenantId = request()->query('tenant_id');
if (is_numeric($requestedTenantId)) {
$tenantId = (string) $requestedTenantId;
$this->tableFilters['tenant_id']['value'] = $tenantId;
$this->tableDeferredFilters['tenant_id']['value'] = $tenantId;
}
$requestedTab = request()->query('activeTab');
if (in_array($requestedTab, ['all', 'active', 'blocked', 'succeeded', 'partial', 'failed'], true)) {
$this->activeTab = (string) $requestedTab;
}
}
} }

View File

@ -57,6 +57,7 @@ public function mount(): void
$this->syncCanonicalAdminTenantFilterState(); $this->syncCanonicalAdminTenantFilterState();
parent::mount(); parent::mount();
$this->applyRequestedDashboardPrefilter();
} }
protected function getHeaderWidgets(): array protected function getHeaderWidgets(): array
@ -357,6 +358,61 @@ private function syncCanonicalAdminTenantFilterState(): void
); );
} }
private function applyRequestedDashboardPrefilter(): void
{
$requestedTab = request()->query('tab');
$requestedStatus = request()->query('status');
$requestedFindingType = request()->query('finding_type');
$requestedGovernanceValidity = request()->query('governance_validity');
$requestedHighSeverity = request()->query('high_severity');
$hasDashboardPrefilter = $requestedTab !== null
|| $requestedStatus !== null
|| $requestedFindingType !== null
|| $requestedGovernanceValidity !== null
|| $requestedHighSeverity !== null;
if (! $hasDashboardPrefilter) {
return;
}
foreach (['status', 'finding_type', 'workflow_family', 'governance_validity'] as $filterName) {
data_forget($this->tableFilters, $filterName);
data_forget($this->tableDeferredFilters, $filterName);
}
foreach (['high_severity', 'overdue', 'my_assigned'] as $filterName) {
data_forget($this->tableFilters, "{$filterName}.isActive");
data_forget($this->tableDeferredFilters, "{$filterName}.isActive");
}
if (in_array($requestedTab, array_keys($this->getTabs()), true)) {
$this->activeTab = (string) $requestedTab;
}
if (is_string($requestedStatus) && $requestedStatus !== '') {
$this->tableFilters['status']['value'] = $requestedStatus;
$this->tableDeferredFilters['status']['value'] = $requestedStatus;
}
if (is_string($requestedFindingType) && $requestedFindingType !== '') {
$this->tableFilters['finding_type']['value'] = $requestedFindingType;
$this->tableDeferredFilters['finding_type']['value'] = $requestedFindingType;
}
if (is_string($requestedGovernanceValidity) && $requestedGovernanceValidity !== '') {
$this->tableFilters['governance_validity']['value'] = $requestedGovernanceValidity;
$this->tableDeferredFilters['governance_validity']['value'] = $requestedGovernanceValidity;
}
$highSeverity = filter_var($requestedHighSeverity, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
if ($highSeverity === true) {
$this->tableFilters['high_severity']['isActive'] = true;
$this->tableDeferredFilters['high_severity']['isActive'] = true;
}
}
private function filterIsActive(string $filterName): bool private function filterIsActive(string $filterName): bool
{ {
$state = $this->getTableFilterState($filterName); $state = $this->getTableFilterState($filterName);

View File

@ -6,10 +6,16 @@
use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCompareSummaryAssessment;
use App\Support\Baselines\TenantGovernanceAggregate; use App\Support\Baselines\TenantGovernanceAggregate;
use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\Rbac\UiTooltips;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
@ -31,7 +37,9 @@ protected function getViewData(): array
'landingUrl' => null, 'landingUrl' => null,
'runUrl' => null, 'runUrl' => null,
'findingsUrl' => null, 'findingsUrl' => null,
'nextActionLabel' => null,
'nextActionUrl' => null, 'nextActionUrl' => null,
'nextActionHelperText' => null,
'summaryAssessment' => null, 'summaryAssessment' => null,
]; ];
@ -46,16 +54,25 @@ protected function getViewData(): array
} }
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant); $tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
$runUrl = $aggregate->stats->operationRunId !== null $operationsFollowUpCount = (int) OperationRun::query()
? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant) ->where('tenant_id', (int) $tenant->getKey())
: null; ->dashboardNeedsFollowUp()
$findingsUrl = FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant); ->count();
$nextActionUrl = match ($aggregate->nextActionTarget) { $summaryAssessment = $this->dashboardSummaryAssessment($aggregate, $operationsFollowUpCount);
$runUrl = $this->runUrl($tenant, $aggregate);
$findingsUrl = $this->findingsUrl($tenant, $aggregate);
$nextActionTarget = (string) ($summaryAssessment['dashboardNextActionTarget'] ?? (($summaryAssessment['nextAction']['target'] ?? 'none') ?: 'none'));
$nextActionLabel = (string) ($summaryAssessment['nextAction']['label'] ?? '');
$nextActionUrl = match ($nextActionTarget) {
'run' => $runUrl, 'run' => $runUrl,
'findings' => $findingsUrl, 'findings' => $findingsUrl,
'landing' => $tenantLandingUrl, 'landing' => $tenantLandingUrl,
'operations' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
default => null, default => null,
}; };
$nextActionHelperText = in_array($nextActionTarget, ['run', 'findings'], true) && $nextActionUrl === null
? UiTooltips::INSUFFICIENT_PERMISSION
: null;
return [ return [
'hasAssignment' => true, 'hasAssignment' => true,
@ -64,11 +81,123 @@ protected function getViewData(): array
'landingUrl' => $tenantLandingUrl, 'landingUrl' => $tenantLandingUrl,
'runUrl' => $runUrl, 'runUrl' => $runUrl,
'findingsUrl' => $findingsUrl, 'findingsUrl' => $findingsUrl,
'nextActionLabel' => $nextActionLabel !== '' ? $nextActionLabel : null,
'nextActionUrl' => $nextActionUrl, 'nextActionUrl' => $nextActionUrl,
'summaryAssessment' => $aggregate->summaryAssessment->toArray(), 'nextActionHelperText' => $nextActionHelperText,
'summaryAssessment' => $summaryAssessment,
]; ];
} }
/**
* @return array<string, mixed>
*/
private function dashboardSummaryAssessment(TenantGovernanceAggregate $aggregate, int $operationsFollowUpCount): array
{
$summaryAssessment = $aggregate->summaryAssessment->toArray();
if (($summaryAssessment['stateFamily'] ?? null) !== BaselineCompareSummaryAssessment::STATE_POSITIVE) {
return $summaryAssessment;
}
if ($aggregate->highSeverityActiveFindingsCount > 0) {
$count = $aggregate->highSeverityActiveFindingsCount;
return array_merge($summaryAssessment, [
'stateFamily' => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
'tone' => 'danger',
'headline' => sprintf('%d high-severity active finding%s need review.', $count, $count === 1 ? '' : 's'),
'supportingMessage' => 'The latest compare may be healthy, but the tenant still has active high-severity findings.',
'highSeverityCount' => $count,
'nextAction' => [
'label' => 'Open findings',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
],
'dashboardNextActionTarget' => 'findings',
]);
}
if ($operationsFollowUpCount > 0) {
return array_merge($summaryAssessment, [
'stateFamily' => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
'tone' => 'danger',
'headline' => sprintf('%d operation%s need follow-up.', $operationsFollowUpCount, $operationsFollowUpCount === 1 ? '' : 's'),
'supportingMessage' => 'Failed, warning, or stalled runs still need review before this tenant reads as fully calm.',
'nextAction' => [
'label' => 'Open operations',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
],
'dashboardNextActionTarget' => 'operations',
]);
}
return $summaryAssessment;
}
private function runUrl(Tenant $tenant, TenantGovernanceAggregate $aggregate): ?string
{
$runId = $aggregate->stats->operationRunId;
if (! is_int($runId)) {
return null;
}
$run = OperationRun::query()->find($runId);
if (! $run instanceof OperationRun || ! $this->canOpenRun($run)) {
return null;
}
return OperationRunLinks::view($run, $tenant);
}
private function findingsUrl(Tenant $tenant, TenantGovernanceAggregate $aggregate): ?string
{
if (! $this->canOpenFindings($tenant)) {
return null;
}
$parameters = match (true) {
$aggregate->lapsedGovernanceCount > 0 => [
'tab' => 'risk_accepted',
'governance_validity' => FindingException::VALIDITY_MISSING_SUPPORT,
],
$aggregate->overdueOpenFindingsCount > 0 => [
'tab' => 'overdue',
],
$aggregate->expiringGovernanceCount > 0 => [
'tab' => 'risk_accepted',
'governance_validity' => FindingException::VALIDITY_EXPIRING,
],
$aggregate->highSeverityActiveFindingsCount > 0 => [
'tab' => 'needs_action',
'high_severity' => 1,
],
$aggregate->visibleDriftFindingsCount > 0 => [
'tab' => 'needs_action',
'finding_type' => 'drift',
],
default => [],
};
return FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant);
}
private function canOpenFindings(Tenant $tenant): bool
{
$user = auth()->user();
return $user instanceof User
&& $user->canAccessTenant($tenant)
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
}
private function canOpenRun(OperationRun $run): bool
{
$user = auth()->user();
return $user instanceof User && $user->can('view', $run);
}
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
{ {
/** @var TenantGovernanceAggregateResolver $resolver */ /** @var TenantGovernanceAggregateResolver $resolver */

View File

@ -8,7 +8,11 @@
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\ActiveRuns; use App\Support\OpsUx\ActiveRuns;
use App\Support\Rbac\UiTooltips;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
@ -36,56 +40,107 @@ protected function getStats(): array
$tenant = Filament::getTenant(); $tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return [ return $this->emptyStats();
Stat::make('Open drift findings', 0),
Stat::make('High severity drift', 0),
Stat::make('Active operations', 0),
Stat::make('Inventory active', 0),
];
} }
$tenantId = (int) $tenant->getKey(); $tenantId = (int) $tenant->getKey();
$openDriftFindings = (int) Finding::query() $openDriftFindings = (int) Finding::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->openDrift()
->where('status', Finding::STATUS_NEW)
->count(); ->count();
$highSeverityDriftFindings = (int) Finding::query() $highSeverityActiveFindings = (int) Finding::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->highSeverityActive()
->where('status', Finding::STATUS_NEW)
->where('severity', Finding::SEVERITY_HIGH)
->count(); ->count();
$activeRuns = (int) OperationRun::query() $activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->active() ->healthyActive()
->count(); ->count();
$inventoryActiveRuns = (int) OperationRun::query() $followUpRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->where('type', 'inventory_sync') ->dashboardNeedsFollowUp()
->active()
->count(); ->count();
$openDriftUrl = $openDriftFindings > 0
? $this->findingsUrl($tenant, [
'tab' => 'needs_action',
'finding_type' => Finding::FINDING_TYPE_DRIFT,
])
: null;
$highSeverityUrl = $highSeverityActiveFindings > 0
? $this->findingsUrl($tenant, [
'tab' => 'needs_action',
'high_severity' => 1,
])
: null;
$findingsHelperText = $this->findingsHelperText($tenant);
return [ return [
Stat::make('Open drift findings', $openDriftFindings) Stat::make('Open drift findings', $openDriftFindings)
->description('across all policy types') ->description($openDriftUrl === null && $openDriftFindings > 0
->url(FindingResource::getUrl('index', tenant: $tenant)), ? $findingsHelperText
Stat::make('High severity drift', $highSeverityDriftFindings) : 'active drift workflow items')
->description('requiring immediate review') ->color($openDriftFindings > 0 ? 'warning' : 'gray')
->color($highSeverityDriftFindings > 0 ? 'danger' : 'gray') ->url($openDriftUrl),
->url(FindingResource::getUrl('index', tenant: $tenant)), Stat::make('High severity active findings', $highSeverityActiveFindings)
->description($highSeverityUrl === null && $highSeverityActiveFindings > 0
? $findingsHelperText
: 'high or critical findings needing review')
->color($highSeverityActiveFindings > 0 ? 'danger' : 'gray')
->url($highSeverityUrl),
Stat::make('Active operations', $activeRuns) Stat::make('Active operations', $activeRuns)
->description('backup, sync & compare operations') ->description('healthy queued or running tenant work')
->color($activeRuns > 0 ? 'warning' : 'gray') ->color($activeRuns > 0 ? 'info' : 'gray')
->url(route('admin.operations.index')), ->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null),
Stat::make('Inventory syncs running', $inventoryActiveRuns) Stat::make('Operations needing follow-up', $followUpRuns)
->description('active inventory sync jobs') ->description('failed, warning, or stalled runs')
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray') ->color($followUpRuns > 0 ? 'danger' : 'gray')
->url(route('admin.operations.index')), ->url($followUpRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'blocked') : null),
]; ];
} }
/**
* @return array<Stat>
*/
private function emptyStats(): array
{
return [
Stat::make('Open drift findings', 0),
Stat::make('High severity active findings', 0),
Stat::make('Active operations', 0),
Stat::make('Operations needing follow-up', 0),
];
}
/**
* @param array<string, mixed> $parameters
*/
private function findingsUrl(Tenant $tenant, array $parameters): ?string
{
if (! $this->canOpenFindings($tenant)) {
return null;
}
return FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant);
}
private function findingsHelperText(Tenant $tenant): string
{
return $this->canOpenFindings($tenant)
? 'Open findings'
: UiTooltips::INSUFFICIENT_PERMISSION;
}
private function canOpenFindings(Tenant $tenant): bool
{
$user = auth()->user();
return $user instanceof User
&& $user->canAccessTenant($tenant)
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
}
} }

View File

@ -4,10 +4,18 @@
namespace App\Filament\Widgets\Dashboard; namespace App\Filament\Widgets\Dashboard;
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\TenantGovernanceAggregate; use App\Support\Baselines\TenantGovernanceAggregate;
use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\ActiveRuns; use App\Support\OpsUx\ActiveRuns;
use App\Support\Rbac\UiTooltips;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
@ -40,69 +48,109 @@ 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()
->where('tenant_id', $tenantId)
->dashboardNeedsFollowUp()
->count();
$activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->healthyActive()
->count();
if ($lapsedGovernanceCount > 0) { if ($lapsedGovernanceCount > 0) {
$items[] = [ $items[] = [
'key' => 'lapsed_governance',
'title' => 'Lapsed accepted-risk governance', 'title' => 'Lapsed accepted-risk governance',
'body' => "{$lapsedGovernanceCount} finding(s) need governance follow-up before accepted risk is safe to rely on.", 'body' => "{$lapsedGovernanceCount} accepted-risk finding(s) no longer have valid supporting governance.",
'badge' => 'Governance', 'badge' => 'Governance',
'badgeColor' => 'danger', 'badgeColor' => 'danger',
...$this->findingsAction(
$tenant,
'Open findings',
[
'tab' => 'risk_accepted',
'governance_validity' => FindingException::VALIDITY_MISSING_SUPPORT,
],
),
]; ];
} }
if ($overdueOpenCount > 0) { if ($overdueOpenCount > 0) {
$items[] = [ $items[] = [
'key' => 'overdue_findings',
'title' => 'Overdue findings', 'title' => 'Overdue findings',
'body' => "{$overdueOpenCount} open finding(s) are overdue and still need workflow follow-up.", 'body' => "{$overdueOpenCount} open finding(s) are overdue and still need workflow follow-up.",
'badge' => 'Findings', 'badge' => 'Findings',
'badgeColor' => 'danger', 'badgeColor' => 'danger',
...$this->findingsAction(
$tenant,
'Open findings',
['tab' => 'overdue'],
),
]; ];
} }
if ($expiringGovernanceCount > 0) { if ($expiringGovernanceCount > 0) {
$items[] = [ $items[] = [
'key' => 'expiring_governance',
'title' => 'Expiring accepted-risk governance', 'title' => 'Expiring accepted-risk governance',
'body' => "{$expiringGovernanceCount} finding(s) will need governance review soon.", 'body' => "{$expiringGovernanceCount} accepted-risk finding(s) need governance review soon.",
'badge' => 'Governance', 'badge' => 'Governance',
'badgeColor' => 'warning', 'badgeColor' => 'warning',
...$this->findingsAction(
$tenant,
'Open findings',
[
'tab' => 'risk_accepted',
'governance_validity' => FindingException::VALIDITY_EXPIRING,
],
),
]; ];
} }
if ($highSeverityCount > 0) { if ($highSeverityCount > 0) {
$items[] = [ $items[] = [
'key' => 'high_severity_active_findings',
'title' => 'High severity active findings', 'title' => 'High severity active findings',
'body' => "{$highSeverityCount} active finding(s) need review.", 'body' => "{$highSeverityCount} high or critical finding(s) are still active.",
'badge' => 'Drift', 'badge' => 'Findings',
'badgeColor' => 'danger', 'badgeColor' => 'danger',
...$this->findingsAction(
$tenant,
'Open findings',
[
'tab' => 'needs_action',
'high_severity' => 1,
],
),
]; ];
} }
if ($compareAssessment->stateFamily !== 'positive') { if ($compareAssessment->stateFamily !== 'positive') {
$items[] = [ $items[] = [
'key' => 'baseline_compare_posture',
'title' => 'Baseline compare posture', 'title' => 'Baseline compare posture',
'body' => $compareAssessment->headline, 'body' => $compareAssessment->headline,
'supportingMessage' => $compareAssessment->supportingMessage, 'supportingMessage' => $compareAssessment->supportingMessage,
'badge' => 'Baseline', 'badge' => 'Baseline',
'badgeColor' => $compareAssessment->tone, 'badgeColor' => $compareAssessment->tone,
'nextStep' => $aggregate->nextActionLabel, 'actionLabel' => 'Open Baseline Compare',
'actionUrl' => BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant),
]; ];
} }
$activeRuns = ActiveRuns::existForTenant($tenant) if ($operationsFollowUpCount > 0) {
? (int) \App\Models\OperationRun::query()->where('tenant_id', $tenantId)->active()->count()
: 0;
if ($activeRuns > 0) {
$items[] = [ $items[] = [
'title' => 'Operations in progress', 'key' => 'operations_follow_up',
'body' => "{$activeRuns} run(s) are active.", 'title' => 'Operations need follow-up',
'body' => "{$operationsFollowUpCount} run(s) failed, completed with warnings, or look stalled.",
'badge' => 'Operations', 'badge' => 'Operations',
'badgeColor' => 'warning', 'badgeColor' => 'danger',
'actionLabel' => 'Open operations',
'actionUrl' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
]; ];
} }
$items = array_slice($items, 0, 5);
$healthyChecks = []; $healthyChecks = [];
if ($items === []) { if ($items === []) {
@ -123,10 +171,15 @@ protected function getViewData(): array
'title' => 'No high severity active findings', 'title' => 'No high severity active findings',
'body' => 'No high severity findings are currently open for this tenant.', 'body' => 'No high severity findings are currently open for this tenant.',
], ],
[ $activeRuns > 0
'title' => 'No active operations', ? [
'body' => 'Nothing is currently running for this tenant.', 'title' => 'Operations are active',
], 'body' => "{$activeRuns} run(s) are active, but nothing currently needs follow-up.",
]
: [
'title' => 'No active operations',
'body' => 'Nothing is currently running for this tenant.',
],
]; ];
} }
@ -137,6 +190,33 @@ protected function getViewData(): array
]; ];
} }
/**
* @param array<string, mixed> $parameters
* @return array<string, mixed>
*/
private function findingsAction(Tenant $tenant, string $label, array $parameters): array
{
$url = $this->canOpenFindings($tenant)
? FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant)
: null;
return [
'actionLabel' => $label,
'actionUrl' => $url,
'actionDisabled' => $url === null,
'helperText' => $url === null ? UiTooltips::INSUFFICIENT_PERMISSION : null,
];
}
private function canOpenFindings(Tenant $tenant): bool
{
$user = auth()->user();
return $user instanceof User
&& $user->canAccessTenant($tenant)
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
}
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
{ {
/** @var TenantGovernanceAggregateResolver $resolver */ /** @var TenantGovernanceAggregateResolver $resolver */

View File

@ -143,6 +143,17 @@ public static function openStatusesForQuery(): array
]; ];
} }
/**
* @return array<int, string>
*/
public static function highSeverityValues(): array
{
return [
self::SEVERITY_HIGH,
self::SEVERITY_CRITICAL,
];
}
public static function canonicalizeStatus(?string $status): ?string public static function canonicalizeStatus(?string $status): ?string
{ {
if ($status === self::STATUS_ACKNOWLEDGED) { if ($status === self::STATUS_ACKNOWLEDGED) {
@ -245,4 +256,41 @@ public function scopeWithSubjectDisplayName(Builder $query): Builder
->limit(1), ->limit(1),
]); ]);
} }
public function scopeOpenWorkflow(Builder $query): Builder
{
return $query->whereIn('status', self::openStatusesForQuery());
}
public function scopeDrift(Builder $query): Builder
{
return $query->where('finding_type', self::FINDING_TYPE_DRIFT);
}
public function scopeOpenDrift(Builder $query): Builder
{
return $query
->drift()
->openWorkflow();
}
public function scopeOverdueOpen(Builder $query): Builder
{
return $query
->openWorkflow()
->whereNotNull('due_at')
->where('due_at', '<', now());
}
public function scopeHighSeverity(Builder $query): Builder
{
return $query->whereIn('severity', self::highSeverityValues());
}
public function scopeHighSeverityActive(Builder $query): Builder
{
return $query
->openWorkflow()
->highSeverity();
}
} }

View File

@ -3,6 +3,9 @@
namespace App\Models; namespace App\Models;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Operations\OperationRunFreshnessState; use App\Support\Operations\OperationRunFreshnessState;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -66,7 +69,127 @@ public function user(): BelongsTo
public function scopeActive(Builder $query): Builder public function scopeActive(Builder $query): Builder
{ {
return $query->whereIn('status', ['queued', 'running']); return $query->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
]);
}
public function scopeTerminalFailure(Builder $query): Builder
{
return $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value);
}
public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
{
$policy ??= app(OperationLifecyclePolicy::class);
return $query
->active()
->where(function (Builder $query) use ($policy): void {
foreach ($policy->coveredTypeNames() as $type) {
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
$typeQuery
->where('type', $type)
->where(function (Builder $stateQuery) use ($policy, $type): void {
$stateQuery
->where(function (Builder $queuedQuery) use ($policy, $type): void {
$queuedQuery
->where('status', OperationRunStatus::Queued->value)
->whereNull('started_at')
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
})
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
$runningQuery
->where('status', OperationRunStatus::Running->value)
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
$startedAtQuery
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
$fallbackQuery
->whereNull('started_at')
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
});
});
});
});
});
}
});
}
public function scopeHealthyActive(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
{
$policy ??= app(OperationLifecyclePolicy::class);
$coveredTypes = $policy->coveredTypeNames();
if ($coveredTypes === []) {
return $query->active();
}
return $query
->active()
->where(function (Builder $query) use ($coveredTypes, $policy): void {
$query->whereNotIn('type', $coveredTypes);
foreach ($coveredTypes as $type) {
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
$typeQuery
->where('type', $type)
->where(function (Builder $stateQuery) use ($policy, $type): void {
$stateQuery
->where(function (Builder $queuedQuery) use ($policy, $type): void {
$queuedQuery
->where('status', OperationRunStatus::Queued->value)
->where(function (Builder $freshQueuedQuery) use ($policy, $type): void {
$freshQueuedQuery
->whereNotNull('started_at')
->orWhereNull('created_at')
->orWhere('created_at', '>', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
});
})
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
$runningQuery
->where('status', OperationRunStatus::Running->value)
->where(function (Builder $freshRunningQuery) use ($policy, $type): void {
$freshRunningQuery
->where('started_at', '>', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
$fallbackQuery
->whereNull('started_at')
->where(function (Builder $createdAtQuery) use ($policy, $type): void {
$createdAtQuery
->whereNull('created_at')
->orWhere('created_at', '>', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
});
});
});
});
});
});
}
});
}
public function scopeDashboardNeedsFollowUp(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query
->where(function (Builder $terminalQuery): void {
$terminalQuery
->where('status', OperationRunStatus::Completed->value)
->whereIn('outcome', [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
]);
})
->orWhere(function (Builder $activeQuery): void {
$activeQuery->likelyStale();
});
});
} }
public function getSelectionHashAttribute(): ?string public function getSelectionHashAttribute(): ?string
@ -194,6 +317,19 @@ public function freshnessState(): OperationRunFreshnessState
return OperationRunFreshnessState::forRun($this); return OperationRunFreshnessState::forRun($this);
} }
public function requiresDashboardFollowUp(): bool
{
if ((string) $this->status === OperationRunStatus::Completed->value) {
return in_array((string) $this->outcome, [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
], true);
}
return $this->freshnessState()->isLikelyStale();
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */

View File

@ -75,9 +75,22 @@ public static function identifier(OperationRun|int $run): string
return 'Operation #'.$runId; return 'Operation #'.$runId;
} }
public static function index(?Tenant $tenant = null, ?CanonicalNavigationContext $context = null): string public static function index(
{ ?Tenant $tenant = null,
return route('admin.operations.index', $context?->toQuery() ?? []); ?CanonicalNavigationContext $context = null,
?string $activeTab = null,
): string {
$parameters = $context?->toQuery() ?? [];
if ($tenant instanceof Tenant) {
$parameters['tenant_id'] = (int) $tenant->getKey();
}
if (is_string($activeTab) && $activeTab !== '') {
$parameters['activeTab'] = $activeTab;
}
return route('admin.operations.index', $parameters);
} }
public static function tenantlessView(OperationRun|int $run, ?CanonicalNavigationContext $context = null): string public static function tenantlessView(OperationRun|int $run, ?CanonicalNavigationContext $context = null): string

View File

@ -18,7 +18,7 @@
:active="$this->activeTab === 'blocked'" :active="$this->activeTab === 'blocked'"
wire:click="$set('activeTab', 'blocked')" wire:click="$set('activeTab', 'blocked')"
> >
Blocked by prerequisite Needs 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'"

View File

@ -120,13 +120,23 @@
<div> <div>
@if (filled($nextActionUrl)) @if (filled($nextActionUrl))
<x-filament::link :href="$nextActionUrl" size="sm" class="font-medium"> <x-filament::link :href="$nextActionUrl" size="sm" class="font-medium">
{{ $nextAction['label'] }} {{ $nextActionLabel ?? $nextAction['label'] }}
</x-filament::link> </x-filament::link>
@elseif (filled($nextActionLabel ?? null))
<div class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-300">
{{ $nextActionLabel }}
</div>
@elseif (filled($nextAction['label'] ?? null)) @elseif (filled($nextAction['label'] ?? null))
<div class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-300"> <div class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-300">
{{ $nextAction['label'] }} {{ $nextAction['label'] }}
</div> </div>
@endif @endif
@if (filled($nextActionHelperText ?? null))
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $nextActionHelperText }}
</div>
@endif
</div> </div>
</div> </div>
</div> </div>

View File

@ -7,7 +7,7 @@
@if (count($items) === 0) @if (count($items) === 0)
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
Current dashboard signals look trustworthy. Current governance and findings signals look trustworthy.
</div> </div>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
@ -41,9 +41,23 @@ class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
</div> </div>
@endif @endif
@if (filled($item['nextStep'] ?? null)) @if (filled($item['actionLabel'] ?? null))
<div class="mt-2 text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"> <div class="mt-3">
{{ $item['nextStep'] }} @if (filled($item['actionUrl'] ?? null))
<x-filament::link :href="$item['actionUrl']" size="sm" class="font-medium">
{{ $item['actionLabel'] }}
</x-filament::link>
@else
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $item['actionLabel'] }}
</div>
@endif
</div>
@endif
@if (filled($item['helperText'] ?? null))
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $item['helperText'] }}
</div> </div>
@endif @endif
</div> </div>

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Tenant Dashboard KPI & Attention Truth Alignment
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-03
**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 1: complete.
- The spec stays bounded to the existing tenant dashboard surface family and explicitly rejects a new global tenant-posture component or persisted dashboard aggregate.
- The main validation focus was keeping tenant-level KPI, attention, compare, and recent surfaces aligned around the same tenant truth while preserving clear separation between posture, activity, and recency.

View File

@ -0,0 +1,553 @@
openapi: 3.1.0
info:
title: Tenant Dashboard Truth Alignment Internal Surface Contract
version: 0.1.0
summary: Internal logical contract for aligned tenant dashboard KPI, attention, compare, and recency surfaces
description: |
This contract is an internal planning artifact for Spec 173. It documents how
the tenant dashboard's summary and recency surfaces must derive their meaning
from existing tenant truth and how drill-through destinations must preserve
that meaning. The rendered routes still return HTML. The structured schemas
below describe the internal page and widget models that must be derivable
before rendering. This does not add a public HTTP API.
servers:
- url: /internal
x-dashboard-consumers:
- surface: tenant.dashboard.kpis
summarySource:
- finding_status_helpers
- finding_destination_filters
- canonical_operations_links
guardScope:
- app/Filament/Widgets/Dashboard/DashboardKpis.php
expectedContract:
- each_kpi_label_matches_its_count_universe
- each_kpi_destination_reproduces_or_explicitly_broadens_the_named_subset
- surface: tenant.dashboard.needs_attention
summarySource:
- tenant_governance_aggregate
- operation_run_activity
guardScope:
- app/Filament/Widgets/Dashboard/NeedsAttention.php
expectedContract:
- each_primary_item_has_one_tenant_safe_destination
- healthy_fallback_is_hidden_when_any_attention_condition_exists
- surface: tenant.dashboard.baseline_compare_now
summarySource:
- tenant_governance_aggregate
- baseline_compare_summary_assessment
guardScope:
- app/Filament/Widgets/Dashboard/BaselineCompareNow.php
expectedContract:
- positive_compare_claims_do_not_outvote_stronger_attention_conditions
- primary_compare_destination_uses_existing_baseline_compare_landing
- surface: tenant.dashboard.recent_drift_findings
summarySource:
- recent_drift_query
guardScope:
- app/Filament/Widgets/Dashboard/RecentDriftFindings.php
expectedContract:
- surface_role_is_diagnostic_recency_not_primary_queue
- row_click_uses_canonical_finding_detail
- surface: tenant.dashboard.recent_operations
summarySource:
- recent_operations_query
- canonical_operations_links
guardScope:
- app/Filament/Widgets/Dashboard/RecentOperations.php
expectedContract:
- surface_role_is_diagnostic_recency_not_primary_queue
- row_click_uses_canonical_operation_detail
paths:
/admin/t/{tenant}:
get:
summary: Render the aligned tenant dashboard summary bundle
operationId: viewTenantDashboardAlignedTruth
parameters:
- name: tenant
in: path
required: true
schema:
type: string
responses:
'200':
description: Tenant dashboard rendered with aligned KPI, attention, compare, and recency semantics
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.tenant-dashboard-truth+json:
schema:
$ref: '#/components/schemas/TenantDashboardTruthBundle'
'404':
description: Tenant is outside workspace or tenant entitlement scope
/admin/t/{tenant}/findings:
get:
summary: Tenant findings destination used by KPI and attention drill-throughs
operationId: openTenantFindingsFromDashboard
parameters:
- name: tenant
in: path
required: true
schema:
type: string
- name: tab
in: query
required: false
schema:
$ref: '#/components/schemas/FindingsTab'
- name: high_severity
in: query
required: false
schema:
type: boolean
- name: status
in: query
required: false
schema:
type: string
- name: finding_type
in: query
required: false
schema:
type: string
responses:
'200':
description: Tenant findings list opened with tenant-safe dashboard continuity filters
'403':
description: Actor is in scope but lacks findings inspection capability
'404':
description: Tenant is outside workspace or tenant entitlement scope
/admin/t/{tenant}/baseline-compare-landing:
get:
summary: Tenant baseline compare landing used by compare and attention drill-throughs
operationId: openTenantBaselineCompareLandingFromDashboard
parameters:
- name: tenant
in: path
required: true
schema:
type: string
responses:
'200':
description: Tenant baseline compare landing opened with the same tenant-context compare posture the dashboard summarized
'403':
description: Actor is in scope but lacks baseline compare inspection capability
'404':
description: Tenant is outside workspace or tenant entitlement scope
/admin/t/{tenant}/findings/{record}:
get:
summary: Tenant finding detail opened from recent drift row-click inspection
operationId: openTenantFindingDetailFromDashboardRecency
parameters:
- name: tenant
in: path
required: true
schema:
type: string
- name: record
in: path
required: true
schema:
type: string
responses:
'200':
description: Tenant finding detail opened from the recent drift diagnostic surface
'403':
description: Actor is in scope but lacks finding detail inspection capability
'404':
description: Tenant or finding is outside entitlement scope
/admin/operations:
get:
summary: Canonical operations destination with tenant-prefilter continuity from the tenant dashboard
operationId: openCanonicalOperationsFromDashboard
parameters:
- name: tenant_id
in: query
required: false
schema:
type:
- integer
- string
description: Tenant filter carried forward from tenant-context dashboard navigation.
- name: activeTab
in: query
required: false
description: Uses `active` for healthy queued or running activity, `blocked` for warning, stalled, or unusually long-running follow-up needing review, and `failed` for terminal failure follow-up.
schema:
$ref: '#/components/schemas/OperationsTab'
- name: navigationContext
in: query
required: false
schema:
type: string
description: Optional serialized canonical navigation context carried from tenant dashboard drill-throughs.
responses:
'200':
description: Canonical admin operations list filtered to the originating tenant when opened from the dashboard
'403':
description: Actor is in scope but lacks operations inspection capability
'404':
description: Tenant context is outside entitlement scope
/admin/operations/{run}:
get:
summary: Canonical operation detail opened from recent operations row-click inspection
operationId: openOperationDetailFromDashboardRecency
parameters:
- name: run
in: path
required: true
schema:
type:
- integer
- string
responses:
'200':
description: Canonical operation detail opened from the recent operations diagnostic surface
'403':
description: Actor is in scope but lacks operation detail inspection capability
'404':
description: Operation run is outside entitlement scope
components:
schemas:
FindingsTab:
type: string
enum:
- all
- needs_action
- overdue
- risk_accepted
- resolved
OperationsTab:
type: string
description: Shared canonical operations tab semantics where `active` represents healthy queued or running work, `blocked` reproduces warning, stalled, or unusually long-running follow-up, and `failed` reproduces terminal failure follow-up.
enum:
- all
- active
- blocked
- succeeded
- partial
- failed
ProblemFamily:
type: string
enum:
- findings
- governance
- compare
- operations
FindingUniverse:
type: string
enum:
- new_drift_only
- open_drift
- active_findings
SeverityUniverse:
type: string
enum:
- high_only
- high_and_critical
FindingsDestinationFilterState:
type: object
additionalProperties: false
required:
- tenant
properties:
tenant:
type: string
tab:
oneOf:
- $ref: '#/components/schemas/FindingsTab'
- type: 'null'
status:
type:
- string
- 'null'
high_severity:
type:
- boolean
- 'null'
finding_type:
type:
- string
- 'null'
OperationsDestinationFilterState:
type: object
additionalProperties: false
required:
- workspace_id
- tenant_id
properties:
workspace_id:
type: integer
tenant_id:
type:
- integer
- string
activeTab:
oneOf:
- $ref: '#/components/schemas/OperationsTab'
- type: 'null'
navigationContext:
type:
- string
- 'null'
DestinationKind:
type: string
enum:
- tenant_findings
- baseline_compare_landing
- canonical_operations
- operation_detail
- finding_detail
- none
SurfaceDestination:
type: object
additionalProperties: false
allOf:
- if:
properties:
actionDisabled:
const: true
then:
required:
- helperText
required:
- kind
- tenantScoped
- semanticsLabel
properties:
kind:
$ref: '#/components/schemas/DestinationKind'
tenantScoped:
type: boolean
semanticsLabel:
type: string
filterState:
oneOf:
- $ref: '#/components/schemas/FindingsDestinationFilterState'
- $ref: '#/components/schemas/OperationsDestinationFilterState'
- type: 'null'
actionDisabled:
type:
- boolean
- 'null'
helperText:
type:
- string
- 'null'
ActionableDestinationKind:
type: string
enum:
- tenant_findings
- baseline_compare_landing
- canonical_operations
KpiDestinationKind:
type: string
enum:
- tenant_findings
- canonical_operations
- none
ActionableSurfaceDestination:
allOf:
- $ref: '#/components/schemas/SurfaceDestination'
- type: object
properties:
kind:
$ref: '#/components/schemas/ActionableDestinationKind'
KpiSurfaceDestination:
allOf:
- $ref: '#/components/schemas/SurfaceDestination'
- type: object
properties:
kind:
$ref: '#/components/schemas/KpiDestinationKind'
KpiMetric:
type: object
additionalProperties: false
required:
- key
- label
- count
- problemFamily
- destination
properties:
key:
type: string
label:
type: string
count:
type: integer
minimum: 0
problemFamily:
type: string
enum:
- findings
- operations
findingUniverse:
oneOf:
- $ref: '#/components/schemas/FindingUniverse'
- type: 'null'
severityUniverse:
oneOf:
- type: string
enum:
- high_only
- high_and_critical
- type: 'null'
destination:
$ref: '#/components/schemas/KpiSurfaceDestination'
AttentionItem:
type: object
additionalProperties: false
anyOf:
- required:
- actionLabel
- required:
- nextStepLabel
- properties:
actionDisabled:
const: true
required:
- actionDisabled
- helperText
required:
- key
- title
- body
- badge
- tone
- problemFamily
- destination
properties:
key:
type: string
title:
type: string
body:
type: string
supportingMessage:
type:
- string
- 'null'
badge:
type: string
tone:
type: string
enum:
- success
- warning
- danger
- info
- gray
problemFamily:
$ref: '#/components/schemas/ProblemFamily'
actionLabel:
type:
- string
- 'null'
actionDisabled:
type:
- boolean
- 'null'
helperText:
type:
- string
- 'null'
nextStepLabel:
type:
- string
- 'null'
destination:
$ref: '#/components/schemas/ActionableSurfaceDestination'
CompareSummary:
type: object
additionalProperties: false
required:
- stateFamily
- tone
- headline
- positiveClaimAllowed
- nextAction
properties:
stateFamily:
type: string
enum:
- positive
- caution
- stale
- action_required
- in_progress
- unavailable
tone:
type: string
enum:
- success
- warning
- danger
- info
- gray
headline:
type: string
supportingMessage:
type:
- string
- 'null'
positiveClaimAllowed:
type: boolean
nextAction:
$ref: '#/components/schemas/SurfaceDestination'
DiagnosticSurfaceSummary:
type: object
additionalProperties: false
required:
- heading
- role
- doesNotDefinePosture
- fullRowClick
- detailDestinationKind
properties:
heading:
type: string
role:
type: string
enum:
- diagnostic_recency
doesNotDefinePosture:
type: boolean
fullRowClick:
type: boolean
detailDestinationKind:
type: string
enum:
- finding_detail
- operation_detail
TenantDashboardTruthBundle:
type: object
additionalProperties: false
required:
- tenantId
- workspaceId
- kpis
- attentionItems
- compareSummary
- recentFindings
- recentOperations
properties:
tenantId:
type: integer
workspaceId:
type: integer
kpis:
type: array
items:
$ref: '#/components/schemas/KpiMetric'
attentionItems:
type: array
items:
$ref: '#/components/schemas/AttentionItem'
compareSummary:
$ref: '#/components/schemas/CompareSummary'
recentFindings:
$ref: '#/components/schemas/DiagnosticSurfaceSummary'
recentOperations:
$ref: '#/components/schemas/DiagnosticSurfaceSummary'

View File

@ -0,0 +1,267 @@
# Phase 1 Data Model: Tenant Dashboard KPI & Attention Truth Alignment
## Overview
This feature does not add a table, persisted summary entity, or new runtime domain subsystem. It aligns existing persistent tenant truth and existing derived summary contracts so the tenant dashboard's KPI, attention, compare, and recency surfaces describe the same tenant reality.
## Persistent Source Truths
### Tenant
**Purpose**: Scope boundary for every dashboard query and every destination opened from the tenant dashboard.
**Key fields**:
- `id`
- `workspace_id`
- `external_id`
**Validation rules**:
- Every dashboard summary and destination must resolve for one explicit tenant scope at a time.
- Canonical admin destinations opened from the dashboard must preserve this tenant scope through filters or navigation context.
### Finding
**Purpose**: Source of drift, workflow, severity, and due-state truth used by tenant dashboard KPI and attention surfaces.
**Key fields**:
- `tenant_id`
- `workspace_id`
- `finding_type`
- `status`
- `severity`
- `due_at`
- `assignee_user_id`
- `scope_key`
- `baseline_operation_run_id`
- `current_operation_run_id`
**Validation rules**:
- Canonical active/open semantics come from `Finding::openStatusesForQuery()`.
- Canonical high-severity tenant-summary semantics use `SEVERITY_HIGH` plus `SEVERITY_CRITICAL`.
- If a dashboard metric intentionally uses a narrower subset, the label and destination must say so explicitly.
**Relevant state families**:
- `new`
- `acknowledged`
- `triaged`
- `in_progress`
- `reopened`
- `risk_accepted`
- `resolved`
- `closed`
### FindingException / Governance Validity
**Purpose**: Supplies expiring and lapsed accepted-risk governance truth used by tenant-level attention and calmness guards.
**Key fields**:
- `tenant_id`
- `workspace_id`
- `finding_id`
- `status`
- `current_validity_state`
- `review_due_at`
- `expires_at`
**Validation rules**:
- Expiring and lapsed governance must remain derived from existing validity state and timing rules.
- No dashboard-local governance state family may replace or reinterpret existing validity truth.
### OperationRun
**Purpose**: Source of tenant activity, compare execution state, and canonical operation detail navigation.
**Key fields**:
- `id`
- `tenant_id`
- `workspace_id`
- `type`
- `status`
- `outcome`
- `created_at`
- `started_at`
- `completed_at`
- `context`
**Validation rules**:
- Canonical active operations semantics come from `OperationRun::scopeActive()` and the Operations page `active` tab.
- Dashboard activity signals must remain distinct from governance posture signals.
- Attention-worthy operations follow-up is narrower than generic activity and is limited to failed, warning, or unusually long-running or stalled tenant runs that require operator review.
## Existing Runtime Source Objects
### BaselineCompareStats
**Purpose**: Existing compare- and governance-aware source object that already owns overdue, expiring, lapsed, and high-severity active findings counts alongside compare posture inputs.
**Key consumed fields**:
- `profileName`
- `state`
- `operationRunId`
- `lastComparedHuman`
- `findingsCount`
- `overdueOpenFindingsCount`
- `expiringGovernanceCount`
- `lapsedGovernanceCount`
- `activeNonNewFindingsCount`
- `highSeverityActiveFindingsCount`
**Validation rules**:
- Existing compare and governance counts remain the source of truth for compare-backed dashboard calmness guards.
- The feature must not create a second competing count path for the same compare-backed summary family.
### BaselineCompareSummaryAssessment
**Purpose**: Existing summary contract that maps compare stats into posture family, tone, headline, supporting message, and next-action intent.
**Key consumed fields**:
- `stateFamily`
- `tone`
- `headline`
- `supportingMessage`
- `reasonCode`
- `positiveClaimAllowed`
- `nextActionLabel()`
- `nextActionTarget()`
**Validation rules**:
- Dashboard compare calmness must remain sourced from this assessment path.
- If the dashboard suppresses additional calm claims beyond compare posture, it must do so by consuming existing tenant attention truth, not by inventing a new tone system.
### TenantGovernanceAggregate
**Purpose**: Existing derived tenant-scoped summary contract that already combines compare posture and governance-related counts for tenant dashboard consumers.
**Key consumed fields**:
- `headline`
- `profileName`
- `lastComparedLabel`
- `compareState`
- `summaryAssessment`
- `overdueOpenFindingsCount`
- `expiringGovernanceCount`
- `lapsedGovernanceCount`
- `highSeverityActiveFindingsCount`
- `nextActionLabel`
- `nextActionTarget`
- `stats`
**Validation rules**:
- The feature should extend or reuse this existing contract rather than creating a new dashboard-only aggregate.
- Widgets using this aggregate must not re-query the same owned summary fields locally.
## Derived Dashboard View Contracts
### Dashboard KPI Metric
**Purpose**: Compact tenant dashboard stat that names one count universe and one matching destination.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | string | yes | Stable metric identity such as `new_drift`, `high_severity_active`, or `active_operations` |
| `label` | string | yes | Operator-facing label that must match the actual count universe |
| `count` | integer | yes | Metric value |
| `problemFamily` | enum | yes | Shared dashboard problem family limited to `findings` or `operations` for the KPI strip in this slice |
| `findingUniverse` | enum nullable | no | `new_drift_only`, `open_drift`, or `active_findings` when applicable |
| `severityUniverse` | enum nullable | no | `high_only` or `high_and_critical` when applicable |
| `destination` | object | yes | Shared destination contract carrying kind, tenant-scoping, semantics label, any filter state needed to reproduce the same subset, and disabled/helper-text state when a visible affordance is intentionally non-clickable |
#### Validation rules
- The metric label must accurately reflect `findingUniverse` and `severityUniverse` when either is present.
- The destination must reproduce the same subset or explicitly broaden it with visible framing.
- The metric `problemFamily` must use the same shared family naming used by `AttentionItem` and the internal OpenAPI contract.
- If a KPI remains visible for an in-scope member who lacks destination capability, the shared `destination` contract must carry the disabled state and helper text instead of implying a clickable drill-through.
- In this slice, KPI destinations are limited to `tenant_findings` or `canonical_operations`, with `none` reserved only for intentionally passive reassurance states.
- A canonical operations destination must carry tenant filter state from the dashboard context.
### Needs Attention Item
**Purpose**: One dashboard attention row that describes a tenant-level problem and tells the operator where to go next.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | string | yes | Stable attention identity such as `overdue_findings`, `lapsed_governance`, or `baseline_compare_posture` |
| `title` | string | yes | Operator-facing problem name |
| `body` | string | yes | Short explanation of the risk or follow-up need |
| `supportingMessage` | string nullable | no | Secondary explanatory text when the item needs more context without changing its primary destination |
| `badge` | string | yes | Existing summary family label such as `Findings`, `Governance`, `Baseline`, or `Operations` |
| `tone` | string | yes | Existing tone family used by the shared contract |
| `problemFamily` | enum | yes | `findings`, `governance`, `compare`, or `operations` |
| `actionLabel` | string nullable | no | Primary follow-up verb for this item |
| `actionDisabled` | boolean nullable | no | Whether the visible follow-up state is intentionally non-clickable for an in-scope member lacking destination capability |
| `helperText` | string nullable | no | Helper text explaining why the visible follow-up state is disabled or non-clickable |
| `nextStepLabel` | string nullable | no | Secondary text when the compare assessment already defines the next step |
| `destination` | object | yes | Shared destination contract carrying the target surface semantics, including disabled/helper-text state when the visible follow-up is intentionally non-clickable |
#### Validation rules
- Central attention items must be actionable: `destination` must be present, and the item must expose either `actionLabel`, `nextStepLabel`, or a disabled explanatory state for an in-scope member who lacks the downstream capability.
- If the visible follow-up is disabled, `actionDisabled` must be `true` and `helperText` must be populated.
- Central attention item destinations are limited to `tenant_findings`, `baseline_compare_landing`, or `canonical_operations` in this slice.
- Each item may expose one primary destination only.
- Items derived from `TenantGovernanceAggregate` must reuse its count and posture fields rather than recompute them.
### Findings Destination Filter State
**Purpose**: Structured state needed to make a dashboard findings drill-through semantically recoverable on the tenant findings list.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `tenant` | tenant route key | yes | Tenant route scope |
| `tab` | enum nullable | no | `needs_action`, `overdue`, `risk_accepted`, `resolved`, or `all` |
| `status` | string nullable | no | Explicit status filter when a narrower subset is intended |
| `high_severity` | boolean nullable | no | Whether the destination must enable the high-severity quick filter |
| `finding_type` | string nullable | no | `drift` when the dashboard metric is drift-only |
#### Validation rules
- A dashboard findings link must use at least one of `tab`, `status`, `high_severity`, or `finding_type` when the originating KPI or attention item names a subset narrower than the default list.
- `high_severity=true` must align with the findings list's existing `HIGH + CRITICAL` filter semantics.
### Operations Destination Filter State
**Purpose**: Structured state needed to keep `/admin/operations` tenant-safe and semantically continuous when opened from the tenant dashboard.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `workspace_id` | integer | yes | Existing workspace context |
| `tenant_id` | integer or tenant route key | yes | Active tenant filter for canonical admin operations |
| `activeTab` | enum nullable | no | `active`, `failed`, `blocked`, `succeeded`, `partial`, or `all`; `blocked` reproduces warning, stalled, or unusually long-running follow-up and `failed` reproduces terminal failure follow-up |
| `navigationContext` | string nullable | no | Optional serialized canonical back-link context carried through the canonical operations destination |
#### Validation rules
- Dashboard operations links must preserve tenant filter state.
- Operations activity KPIs should use the `activeTab=active` semantics when the metric names active work.
- Operations follow-up links must use `failed` for terminal failure follow-up and `blocked` for warning, stalled, or unusually long-running follow-up so dashboard semantics stay recoverable on `/admin/operations`.
## Relationships
- One `Tenant` owns many `Finding` rows and many `OperationRun` rows.
- One `Finding` may have zero or one current effective `FindingException` governance state relevant to this slice.
- One `TenantGovernanceAggregate` summarizes one tenant using one `BaselineCompareStats` instance and one `BaselineCompareSummaryAssessment` instance.
- `DashboardKpis`, `NeedsAttention`, and `BaselineCompareNow` consume overlapping derived truth and must remain semantically aligned.
- `RecentDriftFindings` and `RecentOperations` consume the same tenant scope but are diagnostic-only consumers, not posture owners.
## Lifecycle Notes
1. Tenant dashboard loads for one current tenant.
2. Aggregate-backed summary surfaces resolve the current tenant's compare and governance truth.
3. KPI and attention surfaces expose destinations whose filter state must preserve the originating problem family.
4. Recency surfaces expose recent records for context only.
5. Canonical operations and tenant findings destinations resolve within the same tenant scope and remain subject to existing server-side authorization.
## Migration Notes
- No schema migration is required.
- No new persisted artifact is required.
- If implementation needs a new helper, it should stay local to existing `OperationRunLinks`, findings list filter handling, or the existing aggregate path rather than introducing a new dashboard framework.

View File

@ -0,0 +1,278 @@
# Implementation Plan: Tenant Dashboard KPI & Attention Truth Alignment
**Branch**: `173-tenant-dashboard-truth-alignment` | **Date**: 2026-04-03 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/173-tenant-dashboard-truth-alignment/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/173-tenant-dashboard-truth-alignment/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Align the tenant dashboard's five existing overview surfaces around one honest tenant truth without adding new persistence, a new dashboard aggregate, or a new posture framework. The first implementation slice will tighten KPI semantics and tenant-safe drill-throughs using the existing findings and operations destination models, make `NeedsAttention` action-capable while preserving its aggregate-backed attention logic, and keep `BaselineCompareNow` on the existing compare and governance guard path so the dashboard cannot look calmer than the tenant's real state. The second slice will protect the distinction between posture, activity, and recency with focused cross-widget regression coverage and tenant-prefilter continuity tests.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: 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
**Storage**: PostgreSQL unchanged; no new persistence, cache store, or durable dashboard summary artifact
**Testing**: Pest 4 feature and Livewire component tests through Laravel Sail; existing dashboard tenant-scope and DB-only tests remain part of the verification pack
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging/production
**Project Type**: web application
**Performance Goals**: Keep tenant dashboard rendering DB-only, preserve canonical drill-through routes, and avoid broadening the current dashboard surface family; DB-only rendering remains the explicit verification target for this slice
**Constraints**: No new tables, no new global tenant-posture component, no new dashboard route family, no cross-tenant leakage, no new destructive actions, recent tables must remain diagnostic surfaces, and canonical admin operations routes must preserve tenant context when entered from the tenant dashboard
**Scale/Scope**: One tenant dashboard page, five existing dashboard surfaces, three main destination families (`/admin/t/{tenant}/findings`, `/admin/t/{tenant}/baseline-compare-landing`, `/admin/operations`), and targeted regression coverage for cross-widget consistency and drill-through continuity
## 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 new inventory or snapshot truth is introduced. The feature reuses existing findings, compare, and operation records. |
| Read/write separation | PASS | PASS | The slice is read-time dashboard alignment only. No new write path, preview path, or mutation surface is added. |
| Graph contract path | N/A | N/A | No Graph calls or contract-registry changes are required. |
| Deterministic capabilities | PASS | PASS | Existing server-side authorization remains authoritative for dashboard destinations. |
| Workspace + tenant isolation | PASS | PASS | Every dashboard destination remains tenant-scoped or canonical-view with tenant-prefilter continuity and existing entitlement checks. |
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`, in-scope members lacking a destination capability remain `403`, and no raw capability checks are introduced. |
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` lifecycle or feedback path is added. Existing operations routes remain canonical. |
| Data minimization | PASS | PASS | No new persistence or broader route exposure is introduced; drill-throughs reuse existing destination data only. |
| Proportionality / no premature abstraction | PASS | PASS | The plan explicitly reuses `TenantGovernanceAggregate`, `Finding` status helpers, and existing route helpers instead of adding a dashboard-specific framework. |
| Persisted truth / behavioral state | PASS | PASS | No new tables, status families, or persisted summary artifacts are planned. |
| UI semantics / few layers | PASS | PASS | The feature aligns existing widgets and helper seams rather than creating a new presentation taxonomy. |
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge and tone domains remain authoritative for finding severity, compare posture, and operation status/outcome. |
| Filament-native UI / Action Surface Contract | PASS | PASS | Existing Filament widgets and tables remain in place. No redundant inspect affordances or new destructive actions are introduced. |
| Filament UX-001 | PASS | PASS | No create/edit/view layout changes. The dashboard keeps attention and compare posture above recency surfaces. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design remains within the current Filament v5 + Livewire v4 stack. |
| Provider registration location | PASS | PASS | No panel or provider registration change is required; Laravel 11+ registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No globally searchable resource behavior changes are part of this slice. |
| Destructive action safety | PASS | PASS | The feature adds no destructive action. |
| Asset strategy | PASS | PASS | No new assets or `filament:assets` deployment changes are needed. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds cross-widget and drill-through tests that verify business truth, not thin UI wiring alone. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/173-tenant-dashboard-truth-alignment/research.md`.
Key decisions:
- Reuse the existing `TenantGovernanceAggregate` and `BaselineCompareStats` truth path instead of creating a new tenant-dashboard aggregate.
- Treat `Finding::openStatusesForQuery()` and the existing findings list tabs and quick filters as the canonical active/open universe for dashboard drill-through continuity.
- Treat high severity at tenant-summary level as the existing `HIGH + CRITICAL` active-finding universe; any narrower KPI subset must be explicitly labeled as narrower.
- Make `NeedsAttention` directly actionable by routing each item to an existing tenant-safe findings, compare, or operations destination instead of leaving it as summary-only text.
- Treat materially relevant operations follow-up for this slice as tenant-scoped runs in failed, warning, or unusually long-running or stalled states that require operator review; healthy queued or running activity alone remains an activity signal.
- When a tenant member can see a dashboard summary state but lacks the downstream capability for its destination, keep the state visible only as a disabled or non-clickable affordance with helper text instead of a clickable dead-end link.
- Push tenant-prefilter continuity for canonical Operations routes into existing link and filter helpers rather than leaving raw `route('admin.operations.index')` calls in dashboard widgets.
- Keep `RecentDriftFindings` and `RecentOperations` as diagnostic recency surfaces instead of expanding them into the posture layer.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/173-tenant-dashboard-truth-alignment/`:
- `data-model.md`: existing persistent source truth plus the derived dashboard signal and drill-through contracts for this slice
- `contracts/tenant-dashboard-truth-alignment.openapi.yaml`: internal logical contract for tenant dashboard summary semantics and destination continuity
- `quickstart.md`: focused implementation and verification workflow
Design decisions:
- `TenantGovernanceAggregate` and `BaselineCompareSummaryAssessor` remain the governance and compare truth anchors; the implementation will primarily align widgets and existing link or filter helpers around those outputs, with helper changes kept narrow instead of introducing new aggregate logic.
- KPI semantics are aligned by canonically reusing active-status and severity universes where appropriate and by explicitly renaming or filtering any intentionally narrower subset.
- Canonical operations navigation remains `/admin/operations` and `/admin/operations/{run}`; the change is tenant-prefilter continuity, not a new route family.
- `NeedsAttention` becomes action-capable without becoming a mutation surface or a second diagnostics page, and expiring governance plus high-severity active findings remain first-class attention states alongside overdue, lapsed, compare-limited, and defined operations-follow-up states.
- Permission-limited members keep visible dashboard truth, but destination affordances must be disabled or non-clickable with helper text instead of clickable links that only fail after navigation.
- Recent tables keep their existing row-click model and remain clearly subordinate to attention and compare posture.
## Project Structure
### Documentation (this feature)
```text
specs/173-tenant-dashboard-truth-alignment/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── tenant-dashboard-truth-alignment.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ ├── TenantDashboard.php
│ │ └── Monitoring/
│ │ └── Operations.php
│ ├── Resources/
│ │ └── FindingResource/
│ │ └── Pages/
│ │ └── ListFindings.php
│ └── Widgets/
│ └── Dashboard/
│ ├── DashboardKpis.php
│ ├── NeedsAttention.php
│ ├── BaselineCompareNow.php
│ ├── RecentDriftFindings.php
│ └── RecentOperations.php
├── Models/
│ ├── Finding.php
│ └── OperationRun.php
└── Support/
├── Baselines/
│ ├── BaselineCompareStats.php
│ ├── BaselineCompareSummaryAssessor.php
│ ├── BaselineCompareSummaryAssessment.php
│ ├── TenantGovernanceAggregate.php
│ └── TenantGovernanceAggregateResolver.php
├── Navigation/
│ └── CanonicalNavigationContext.php
└── OperationRunLinks.php
tests/
├── Feature/
│ ├── Filament/
│ │ ├── NeedsAttentionWidgetTest.php
│ │ ├── BaselineCompareNowWidgetTest.php
│ │ ├── BaselineCompareSummaryConsistencyTest.php
│ │ ├── TenantDashboardDbOnlyTest.php
│ │ ├── TenantDashboardTenantScopeTest.php
│ │ ├── DashboardKpisWidgetTest.php
│ │ └── TenantDashboardTruthAlignmentTest.php
│ ├── Findings/
│ │ ├── FindingsListDefaultsTest.php
│ │ ├── FindingsListFiltersTest.php
│ │ └── FindingAdminTenantParityTest.php
│ ├── Monitoring/
│ │ └── OperationsDashboardDrillthroughTest.php
│ └── OpsUx/
│ └── CanonicalViewRunLinksTest.php
```
**Structure Decision**: Keep the existing Laravel monolith structure. The implementation should extend current dashboard widgets, current findings and operations destinations, and current baseline aggregate helpers instead of introducing new directories or a dashboard-specific domain layer.
## Implementation Strategy
### Phase A — Align Canonical Dashboard Truth Definitions
**Goal**: Make dashboard count meaning and severity meaning reuse existing source truth instead of widget-local interpretations.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Models/Finding.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php` | Treat `openStatusesForQuery()`, existing `Needs action` and `Overdue` tabs, and the existing `high_severity` quick filter as the canonical active/open and high-severity destination semantics for tenant findings drill-throughs. |
| A.2 | `app/Support/Baselines/BaselineCompareStats.php`, `app/Support/Baselines/TenantGovernanceAggregate.php`, and `app/Support/Baselines/TenantGovernanceAggregateResolver.php` | Preserve the current aggregate-backed compare and governance count family as the canonical tenant-level attention guard set. |
| A.3 | `app/Filament/Widgets/Dashboard/DashboardKpis.php` | Replace ambiguous KPI wording and local count universes with metrics that either match the canonical active/severity meaning or are explicitly labeled as narrower subsets, and degrade visible KPI drill-throughs to disabled or non-clickable helper-text affordances when destination capability is missing. |
### Phase B — Make KPI Drill-Throughs Semantically Continuous
**Goal**: Ensure clicking a KPI leads to a target surface where the same problem family is recognizable.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php` | Add or reuse explicit findings tab/filter state so KPI destinations reproduce the named subset instead of opening a broader unqualified findings list. |
| B.2 | `app/Support/OperationRunLinks.php` and `app/Filament/Pages/Monitoring/Operations.php` | Push tenant-prefilter continuity for canonical operations links into the existing operations link helper and destination filter handling instead of relying on raw route calls. |
| B.3 | `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, and `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` | Prove KPI naming, subset meaning, destination continuity, and permission-limited disabled or non-clickable affordance behavior for findings and operations drill-throughs. |
### Phase C — Make `NeedsAttention` Action-Capable
**Goal**: Turn the existing attention summary into a start surface without changing its role into a mutation or diagnostics surface.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | Add direct destination metadata for central attention items while keeping the existing aggregate-backed problem detection, high-severity active-finding coverage, expiring-governance coverage, defined operations-follow-up semantics, and healthy fallback rules. |
| C.2 | `resources/views/filament/widgets/dashboard/needs-attention.blade.php` | Render one primary action or one explicit next-step affordance per attention item while preserving the surface's summary-first hierarchy. |
| C.3 | `tests/Feature/Filament/NeedsAttentionWidgetTest.php` and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php` | Prove that overdue findings, high-severity active findings, lapsed or expiring governance, compare posture limitations, relevant operations follow-up, and permission-limited dashboard states now expose the correct tenant-safe destination behavior and suppress false calm. |
### Phase D — Keep Compare Calmness and Dashboard Calmness Aligned
**Goal**: Ensure `BaselineCompareNow` and the dashboard's healthy fallback do not outvote stronger tenant attention conditions.
| Step | File | Change |
|------|------|--------|
| D.1 | `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` | Preserve the existing compare summary guard path and baseline-compare landing continuity by consuming the current aggregate-backed summary outputs consistently, without introducing a second helper-owned compare logic path in this slice. |
| D.2 | `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php` | Prove that compare summary and dashboard attention continue to agree for stale, unavailable, overdue, expiring-governance, lapsed-governance, and trustworthy scenarios. |
### Phase E — Preserve Posture vs Activity vs Recency Separation
**Goal**: Keep recent tables diagnostic and keep operations activity distinct from governance posture.
| Step | File | Change |
|------|------|--------|
| E.1 | `app/Filament/Widgets/Dashboard/RecentDriftFindings.php` and `app/Filament/Widgets/Dashboard/RecentOperations.php` | Preserve current row-click, empty-state, and recency-only semantics; tighten only copy or surrounding truth signals if needed so these surfaces do not read as the tenant's primary queue. |
| E.2 | `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, and existing table standards tests | Prove that recency surfaces remain secondary to attention and compare posture, that healthy operations-only scenarios do not produce governance-problem wording, and that failed, warning, or stalled operations follow-up remains distinguishable from simple activity. |
### Phase F — Regression Protection and Verification
**Goal**: Protect the dashboard truth contract against future drift.
| Step | File | Change |
|------|------|--------|
| F.1 | `tests/Feature/Filament/DashboardKpisWidgetTest.php` and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php` | Add a focused matrix for active findings, high severity, overdue, lapsed, or expiring governance, compare limitations, healthy operations-only activity, attention-worthy operations follow-up, and calm all-clear scenarios. |
| F.2 | `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`, and `tests/Feature/Filament/TenantDashboardTenantScopeTest.php` | Protect tenant-filter continuity, tenant-safe access, and disabled or non-clickable dashboard affordances for canonical operations routes opened from the dashboard. |
| F.3 | `vendor/bin/sail bin pint --dirty --format agent` plus focused Pest runs | Apply formatting and run the smallest verification pack that covers widgets, dashboard integration, findings filters, and operations drill-through continuity. |
## Key Design Decisions
### D-001 — Reuse the existing tenant governance aggregate instead of creating a dashboard-specific truth layer
The repo already has `TenantGovernanceAggregate`, `BaselineCompareStats`, and `BaselineCompareSummaryAssessor` for governance and compare truth. Spec 173 should extend that existing ownership to dashboard alignment work instead of introducing a second tenant summary abstraction. Unless regression coverage exposes a defect in those helpers themselves, this slice changes widget consumption and drill-through continuity rather than modifying compare helper behavior.
### D-002 — Canonical finding semantics come from existing model helpers and findings list filters
`Finding::openStatusesForQuery()`, the findings list tabs, and the existing `high_severity` quick filter already define the best current meaning of active/open and high-severity work. KPI and attention drill-through continuity should reuse that semantics instead of inventing dashboard-only filters.
### D-003 — Canonical operations drill-through stays canonical but must preserve tenant context
The correct destination remains `/admin/operations` and `/admin/operations/{run}`. The fix is to carry tenant filter state into those existing routes, not to build tenant-specific duplicate operations pages.
### D-004 — `NeedsAttention` becomes actionable but remains a summary surface
The attention widget should tell the operator where to go next, but it should not become a new diagnostics or mutation surface. One action per item is the right level.
### D-005 — Recent tables remain diagnostic recency surfaces
`RecentDriftFindings` and `RecentOperations` should continue to provide recent context only. The plan explicitly avoids turning them into primary queue or posture owners.
### D-006 — Attention-worthy operations follow-up is narrower than generic activity
For this slice, only failed, warning, or unusually long-running or stalled tenant runs count as attention-worthy operations follow-up. Healthy queued or running activity remains visible as activity, not governance risk.
### D-007 — Permission-limited members must not get clickable dead-end drill-throughs
If a tenant member can see dashboard truth but lacks a downstream capability, the dashboard may still expose the state, but the affordance must be disabled or non-clickable with helper text rather than a clickable control that only ends in `403` after navigation.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| KPI semantics over-expand and lose the compact value of the strip | Medium | Medium | Keep narrow subsets only when they are explicitly named and filter-matched on the destination. |
| Canonical operations links still drop tenant context | High | Medium | Push tenant-prefilter continuity into `OperationRunLinks` and destination filter handling, backed by regression tests. |
| `NeedsAttention` becomes clickable but semantically drifts from the aggregate-backed truth or exposes dead-end links | High | Medium | Source item semantics from the existing aggregate, define operations follow-up narrowly, and keep one destination per item with disabled or non-clickable fallback for permission-limited members. |
| Compare summary stays calmer than stronger dashboard attention conditions | High | Medium | Add explicit all-clear suppression tests across `NeedsAttention`, `BaselineCompareNow`, and the dashboard bundle. |
| Recent tables begin to read as the primary action queue | Medium | Low | Preserve current headings, empty-state semantics, and row-click-only interaction; verify this in integration coverage. |
## Test Strategy
- Extend or add focused Livewire and feature tests for `DashboardKpis`, `NeedsAttention`, and `BaselineCompareNow` so the dashboard's core summary surfaces agree on active findings, high severity, overdue, lapsed, and expiring governance, compare limitations, healthy operations-only activity, attention-worthy operations follow-up, and calm all-clear states.
- Reuse the current `TenantDashboardDbOnlyTest` and `TenantDashboardTenantScopeTest` to preserve DB-only rendering and tenant isolation.
- Add explicit drill-through coverage so KPI and attention clicks land on findings or operations destinations with recognizable tenant-filtered semantics, and permission-limited members see disabled or non-clickable explanatory states instead of clickable dead ends.
- Reuse current findings list filter coverage to protect the destination-side meaning of `needs_action`, `overdue`, and `high_severity` continuity.
- Preserve existing compare summary consistency tests so compare posture continues to agree across dashboard and landing surfaces.
- Use the quickstart manual smoke check to verify the 10-second operator comprehension outcome on seeded tenants in addition to the automated regression pack.
- Keep all coverage Pest-based and run through Sail with the smallest targeted verification pack.
## Complexity Tracking
No constitution exception or BLOAT-001-triggering addition is planned. The intended implementation reuses existing aggregate, helper, and page/widget structure.
## Proportionality Review
- **Current operator problem**: The tenant dashboard currently lets KPI counts, attention logic, compare calmness, and recent-history surfaces describe different semantic worlds on the same page.
- **Existing structure is insufficient because**: Widget-local queries and raw route links have diverged from existing canonical findings and compare semantics, and canonical operations links do not currently preserve tenant context from the dashboard.
- **Narrowest correct implementation**: Align the existing widgets, existing aggregate-backed compare truth, existing findings filters, and existing operations link helpers instead of adding new persistence, new routes, or a new dashboard domain model.
- **Ownership cost created**: A focused set of widget and drill-through tests plus a small amount of helper and copy maintenance.
- **Alternative intentionally rejected**: A new tenant-dashboard aggregate, a global posture component, or a layout-level dashboard rewrite was rejected because the current-release problem is semantic alignment on existing surfaces.
- **Release truth**: Current-release truth. The affected widgets and routes already exist and are already used by operators.

View File

@ -0,0 +1,128 @@
# Quickstart: Tenant Dashboard KPI & Attention Truth Alignment
## Goal
Validate that the tenant dashboard no longer appears calmer than the tenant's real governance, findings, compare, and operations state, and that KPI and attention drill-throughs lead to semantically matching destinations.
## Prerequisites
1. Start Sail.
2. Ensure you have a tenant with dashboard access and current workspace context.
3. Seed or create tenant scenarios for:
- no attention-worthy conditions
- overdue active findings with no new drift
- lapsed accepted-risk governance
- expiring governance
- high-severity active findings
- compare trust limitations or stale compare posture
- healthy operations-only activity with otherwise healthy governance
- failed, warning, or unusually long-running or stalled operations that require follow-up
4. Ensure the current user is entitled to the tenant dashboard, tenant findings list, Baseline Compare landing, and canonical Operations routes.
5. Prepare one in-scope tenant member who can open the dashboard but lacks at least one downstream destination capability so disabled or non-clickable affordances can be verified.
## Implementation Validation Order
### 1. Run existing compare and attention truth baselines
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareNowWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php
```
Expected outcome:
- Existing aggregate-backed compare and governance truth remains stable.
- Compare posture still suppresses false calm for stale, unavailable, failed, and limited-confidence scenarios.
### 2. Run focused dashboard truth-alignment coverage
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
```
Expected outcome:
- KPI labels match their count universe.
- High-severity and active findings semantics are consistent or explicitly differentiated.
- The full tenant dashboard does not present an all-clear when overdue, lapsed, or expiring governance, compare-limited conditions, or attention-worthy operations follow-up exist.
### 3. Run destination continuity and tenant-scope coverage
```bash
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardTenantScopeTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardDbOnlyTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStandardsBaselineTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TableDetailVisibilityTest.php
```
Expected outcome:
- Canonical Operations links opened from the tenant dashboard preserve tenant context.
- Dashboard drill-throughs remain tenant-safe and DB-only at render time.
- Members who can see a dashboard state but lack the downstream capability get disabled or non-clickable explanatory affordances instead of clickable dead-end links.
- Recent table surfaces retain their diagnostic framing and detail-visibility rules in the final verification pack.
### 4. Run destination-side findings filter coverage
```bash
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListDefaultsTest.php
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAdminTenantParityTest.php
```
Expected outcome:
- Findings destinations still honor the tabs and filters that dashboard drill-throughs depend on.
- Active, overdue, and high-severity continuity remains recognizable on the target list.
### 5. Format touched implementation files
```bash
vendor/bin/sail bin pint --dirty --format agent
```
Expected outcome:
- All changed implementation files conform to project formatting rules.
### 6. Re-run the final verification pack
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareNowWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListDefaultsTest.php
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAdminTenantParityTest.php
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardTenantScopeTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardDbOnlyTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStandardsBaselineTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TableDetailVisibilityTest.php
```
Expected outcome:
- The formatted implementation still passes the same consolidated verification pack described in the tasks artifact.
## Manual Smoke Check
1. Start a 10-second timer and open `/admin/t/{tenant}` for seeded tenants representing overdue findings, expiring governance, compare limitations, healthy operations-only activity, and attention-worthy operations follow-up.
2. Within the 10-second scan, confirm an operator can tell whether the tenant has governance attention, compare caution, operations-only activity, attention-worthy operations follow-up, or no immediate action required.
3. For a tenant with overdue findings but no `new` drift findings, confirm the dashboard still reads as needing attention and does not fall back to calm or trustworthy wording.
4. Click the relevant KPI and confirm the findings destination shows the same subset or explicitly broader related framing.
5. Click a `Needs Attention` item for overdue findings, high-severity active findings, lapsed governance, expiring governance, compare posture, and operations follow-up and confirm each lands on the correct tenant-scoped working surface.
6. Open the operations KPI or operations attention path and confirm `/admin/operations` opens with tenant context preserved.
7. Sign in as the permission-limited in-scope member and confirm any visible dashboard state without downstream capability renders helper text with a disabled or non-clickable affordance instead of a clickable dead-end link.
8. Verify that `Recent Drift Findings` and `Recent Operations` still read as recent context rather than the page's primary queue.
9. Click one row in `Recent Drift Findings` and one row in `Recent Operations` and confirm each opens the expected canonical detail surface.
10. Switch to a tenant with healthy compare posture and no attention-worthy conditions and confirm calm or healthy signals return consistently.
## Non-Goals For This Slice
- No new database migration.
- No new Graph contract or provider workflow.
- No new tenant-posture hero component.
- No new dashboard route family.
- No conversion of recent tables into full action queues.

View File

@ -0,0 +1,73 @@
# Phase 0 Research: Tenant Dashboard KPI & Attention Truth Alignment
## Decision: Reuse the existing `TenantGovernanceAggregate` and compare summary path instead of creating a new dashboard aggregate
**Rationale**: `NeedsAttention` and `BaselineCompareNow` already depend on `TenantGovernanceAggregate`, which itself is derived from `BaselineCompareStats` and `BaselineCompareSummaryAssessor`. The tenant dashboard truth problem is not missing data; it is divergent interpretation and drill-through behavior across existing widgets. Extending the current aggregate-backed guard family is narrower than inventing a new dashboard-specific summary service.
**Alternatives considered**:
- Create a new persisted tenant-dashboard summary record: rejected because the spec explicitly forbids new persistence and the problem is request-time semantics, not a new lifecycle truth.
- Create a second query-backed dashboard aggregate: rejected because it would split ownership away from the existing compare and governance summary path.
## Decision: Treat `Finding::openStatusesForQuery()` and existing findings tabs and quick filters as the canonical active/open universe for dashboard continuity
**Rationale**: The tenant findings destination already defines `Needs action` and `Overdue` tabs using `Finding::openStatusesForQuery()`, and the findings list already exposes `high_severity`, `overdue`, and status-based quick filtering. These are the closest existing source of truth for what the operator should recognize after clicking a dashboard signal. Dashboard metrics should align to these semantics or be visibly renamed when intentionally narrower.
**Alternatives considered**:
- Keep dashboard-local status semantics and accept looser drill-through continuity: rejected because it preserves the trust gap the spec is trying to remove.
- Introduce a new dashboard-only filter vocabulary: rejected because it would add a second surface contract for the same findings universe.
## Decision: Treat high severity at tenant-summary level as `HIGH + CRITICAL` active findings unless a metric is explicitly labeled as narrower
**Rationale**: `BaselineCompareStats` already counts `highSeverityActiveFindingsCount` using open statuses plus `HIGH + CRITICAL`, and the findings list `high_severity` quick filter uses the same severity set. `DashboardKpis` is the current outlier because it only counts `SEVERITY_HIGH` and `STATUS_NEW`. The narrowest fix is to align `high severity` language to the existing broader tenant-summary meaning and reserve narrower wording for explicitly `new` or `drift-only` subsets.
**Alternatives considered**:
- Downgrade every other surface to `SEVERITY_HIGH` only: rejected because it would discard an existing criticality distinction already present in the findings destination and aggregate.
- Let `high severity` continue to mean different things by widget: rejected because the label collision is part of the operator trust problem.
## Decision: Make `NeedsAttention` directly actionable using existing tenant-safe destinations instead of leaving it as summary-only text
**Rationale**: The current widget already computes the right tenant-level problem families but only exposes summary text and, for compare posture, a `nextStep` label without navigation. The spec requires central attention states to become genuine start points. Existing findings, baseline compare, and operations destinations already exist and are the right follow-up surfaces.
**Alternatives considered**:
- Keep `NeedsAttention` non-navigational and rely on adjacent widgets for follow-up: rejected because it leaves the primary attention surface incomplete.
- Introduce a new dedicated attention page: rejected because the spec explicitly avoids new overview architecture.
## Decision: Keep canonical Operations routes and push tenant-prefilter continuity into existing link and filter helpers
**Rationale**: `/admin/operations` and `/admin/operations/{run}` are already the canonical operations destinations. The current dashboard KPI uses a raw `route('admin.operations.index')`, which loses tenant context. `OperationRunLinks` and the Operations page already provide the right seam to carry tenant-aware filter or navigation context without multiplying route families.
**Alternatives considered**:
- Add tenant-specific operations pages under `/admin/t/{tenant}`: rejected because the repo already standardized operations on canonical admin routes.
- Leave dashboard operations drill-through unfiltered: rejected because it breaks the spec's drill-through continuity requirement.
## Decision: Treat attention-worthy operations follow-up as failed, warning, or unusually long-running or stalled tenant runs
**Rationale**: Spec 173 distinguishes governance posture from operations activity. Healthy queued or running operations should remain visible as activity, but they must not be allowed to suppress or replace governance signals. Only runs whose current status or outcome indicates failure, warning, or unusually long-running or stalled execution should escalate into `NeedsAttention` follow-up.
**Alternatives considered**:
- Treat any active operation as attention-worthy: rejected because it would collapse activity and risk into one noisy signal.
- Ignore operations follow-up completely on the dashboard: rejected because failed or stalled tenant runs do require operator follow-up and are explicitly in scope.
## Decision: Permission-limited members may see truth but must not get clickable dead-end drill-throughs
**Rationale**: The constitution allows visible disabled actions for in-scope members who lack capability, while the spec forbids dead-end drill-throughs. The narrowest consistent behavior is to keep dashboard truth visible but render the affordance as disabled or non-clickable with helper text instead of a clickable link that would only fail after navigation.
**Alternatives considered**:
- Hide the state entirely: rejected because it can make the dashboard look calmer than reality for an otherwise entitled tenant member.
- Allow the click and rely on a downstream `403`: rejected because it violates the no-dead-end drill-through requirement and weakens operator trust.
## Decision: Preserve `RecentDriftFindings` and `RecentOperations` as diagnostic recency surfaces rather than queue surfaces
**Rationale**: Both table widgets already use row-click inspection, default sort, and domain-specific empty states, and their queries intentionally include recent history without filtering to only active work. The spec calls out that recent surfaces can remain diagnostic if that role is explicit. Treating them as recency/context surfaces is narrower and avoids conflating history with posture.
**Alternatives considered**:
- Convert recent tables into tightly filtered actionable queues: rejected because it would expand the feature into a dashboard redesign and overlap with existing findings and operations destinations.
- Remove recent surfaces from the dashboard: rejected because the spec is about truth alignment, not surface removal.
## Decision: Protect the feature with cross-widget parity and drill-through tests instead of one-off manual smoke checks only
**Rationale**: The highest-risk regressions are semantic: mismatched count universes, false calm, and tenant context loss on destinations. Focused Pest tests around widgets and route continuity can protect those business truths directly and stay aligned with the repo's existing Livewire-heavy testing style.
**Alternatives considered**:
- Rely on manual dashboard review only: rejected because subtle truth drift will recur.
- Add a broad browser-only suite: rejected because the core logic is already well served by focused Livewire and feature tests, with existing browser coverage reserved for route and smoke scenarios where needed.

View File

@ -0,0 +1,231 @@
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
# Feature Specification: Tenant Dashboard KPI & Attention Truth Alignment
**Feature Branch**: `173-tenant-dashboard-truth-alignment`
**Created**: 2026-04-03
**Status**: Draft
**Input**: User description: "Spec 173 — Tenant Dashboard KPI & Attention Truth Alignment"
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant + canonical-view
- **Primary Routes**:
- `/admin/t/{tenant}` as the tenant dashboard where `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, `RecentDriftFindings`, and `RecentOperations` currently appear together
- `/admin/t/{tenant}/findings` and tenant finding detail routes as the primary findings drill-through destinations from dashboard summary signals
- `/admin/t/{tenant}/baseline-compare-landing` as the tenant baseline compare truth and next-action destination
- `/admin/operations` and canonical operation detail routes as the operations destinations reached from tenant dashboard activity signals
- **Data Ownership**:
- Tenant-owned: findings, finding governance state, operation runs, and other tenant-scoped operational records that the dashboard summarizes for one selected tenant
- Workspace-owned but tenant-resolved: baseline assignment, baseline profile, and related prerequisites that shape compare posture for the current tenant
- This feature introduces no new tenant-dashboard summary record; KPI, attention, compare, and recent surfaces remain derived views over existing truth sources
- **RBAC**:
- Workspace membership and tenant entitlement remain required for the tenant dashboard and every tenant-context drill-through destination
- Existing findings, baseline compare, and operations inspection permissions remain the enforcement source for what a user may open from the dashboard
- Dashboard links and summary claims must not imply or reveal tenant data beyond the current entitled tenant scope
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Any canonical-view destination opened from the tenant dashboard, especially operations routes, opens prefiltered to the active tenant so the operator lands in the same tenant world they clicked from. Operators may broaden filters only within their entitled tenant set.
- **Explicit entitlement checks preventing cross-tenant leakage**: Dashboard counts, health claims, destination links, canonical route filters, and operation or finding drill-down results must all be resolved only after workspace membership and tenant entitlement checks. Unauthorized users must not learn whether another tenant has open findings, degraded compare trust, or active operations.
## 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 KPI strip | Embedded status summary / drill-in surface | Each KPI opens one matching destination for the exact problem family it names | forbidden | none | none | Tenant findings list or tenant-prefiltered operations list, depending on the KPI | Existing finding detail or operation detail once the operator drills further | The selected tenant context from the dashboard must stay explicit in the landing filter state | Findings / Finding and Operations / Operation | Count meaning, severity meaning, and whether the number is posture or activity | Multi-destination summary surface |
| Tenant dashboard `Needs Attention` | Embedded attention summary | Each attention item exposes one direct destination or one explicit next step for the named problem | forbidden | none | none | Tenant findings list, baseline compare landing, or tenant-prefiltered operations list depending on item type | Existing finding detail or operation detail once the operator drills further | Attention labels must remain tenant-scoped and reflect the same tenant truth as the surrounding dashboard | Findings / Finding, Governance, Baseline Compare, Operations / Operation | Highest-priority tenant problem and the next place to act | Multi-domain attention surface |
| Tenant dashboard `BaselineCompareNow` | Embedded compare posture summary | One primary CTA aligned to the current compare posture or next action | forbidden | supportive links only if they reinforce the same posture | none | `/admin/t/{tenant}/baseline-compare-landing` as the primary compare collection destination | Existing operation detail or tenant findings list when the next action demands a deeper destination | Tenant context, compare posture, and any trust limitation remain visible before navigation | Baseline Compare | Compare posture, trust limits, and next action without conflicting calm claims | Compare-trust summary surface |
| Tenant dashboard `Recent Drift Findings` | Diagnostic recency table | Full-row click to the corresponding finding detail | required | none | none | `/admin/t/{tenant}/findings` | `/admin/t/{tenant}/findings/{record}` | Tenant dashboard context and findings labels keep the table anchored to the selected tenant | Findings / Finding | Recent drift history remains visible without claiming to be the whole active queue | Diagnostic recency surface |
| Tenant dashboard `Recent Operations` | Diagnostic recency table | Full-row click to the corresponding operation detail | required | none | none | `/admin/operations` with tenant prefilter | Existing canonical operation detail route | Tenant dashboard context and visible operation labels keep the table anchored to the selected tenant | Operations / Operation | Recent execution history remains visible without being mistaken for governance posture | Diagnostic recency surface |
## 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 KPI strip | Tenant operator | Embedded status summary / drill-in surface | Do I have active governance problems, critical findings, or live activity that changes what I should do next? | Small set of trustworthy counts with honest labels, clear problem-family separation, and consistent destination meaning | Raw query detail, low-level status taxonomy, and broader historical counts stay off the strip | active findings pressure, high-severity pressure, operations activity | none | Open the matching findings or operations destination from the KPI | none |
| Tenant dashboard `Needs Attention` | Tenant operator | Embedded attention summary | What needs action right now, and where should I start? | Highest-priority attention items, compare caution when relevant, and the next appropriate destination | Deep diagnostic cause chains, raw compare internals, and operation metadata remain secondary | governance attention, overdue urgency, compare trust posture, materially relevant operations status | none | Open findings, open baseline compare, or open tenant-filtered operations follow-up | none |
| Tenant dashboard `BaselineCompareNow` | Tenant operator | Embedded compare posture summary | Is baseline compare trustworthy enough to rely on, and what should I do next? | Compare posture, trust limits, last compared context, and one aligned next step | Deep evidence gaps, duplicate-name diagnostics, and low-level run internals remain secondary | compare availability, compare trust, compare freshness, governance attention | none | Open Baseline Compare, open findings, or open the current operation when that is the next action | none |
| Tenant dashboard `Recent Drift Findings` | Tenant operator | Diagnostic recency table | What drift findings were seen recently if I need context or history? | Recent finding rows with subject, severity, status, and recency | Full finding history, workflow detail, and evidence payload remain on detail pages | recency, severity, workflow status | none | Open the selected finding detail | none |
| Tenant dashboard `Recent Operations` | Tenant operator | Diagnostic recency table | What tenant operations ran recently if I need execution context? | Recent operation rows with operation label, status, outcome, and started time | Full summary counts, traces, and payload diagnostics remain on operation detail pages | recency, execution status, execution outcome | none | Open the selected operation detail | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: No. Findings, governance validity, compare posture, and operation-run truth remain the source data.
- **New persisted entity/table/artifact?**: No. This slice explicitly avoids new summary persistence.
- **New abstraction?**: No. The alignment should be achieved by tightening existing summary definitions, guard rules, and destination semantics rather than adding a new dashboard framework.
- **New enum/state/reason family?**: No. Existing finding, governance, compare, and operation state families remain authoritative.
- **New cross-domain UI framework/taxonomy?**: No. This spec aligns existing tenant-level overview surfaces and explicitly avoids a new global posture system.
- **Current operator problem**: The tenant dashboard can currently present narrower KPI counts, broader attention logic, calmer compare wording, and diagnostic recency tables in ways that make the whole page feel quieter or less coherent than the underlying tenant truth.
- **Existing structure is insufficient because**: Similar claims are owned by separate widget-local counting and guard logic, and some drill-through links lead into destinations that do not clearly match the number or problem statement the operator clicked.
- **Narrowest correct implementation**: Align the existing five dashboard surfaces and their target destinations around shared active-count meaning, severity meaning, calm-claim guards, and tenant-preserving drill-through semantics without redesigning the whole dashboard.
- **Ownership cost**: The repo takes on cross-widget regression coverage, some shared summary-definition hardening, and a small amount of copy or empty-state tightening to keep the dashboard semantically aligned over time.
- **Alternative intentionally rejected**: A full dashboard redesign, a new global tenant-posture indicator, or a new persisted dashboard aggregate was rejected because the immediate problem is truth alignment on existing tenant-level overview surfaces.
- **Release truth**: Current-release truth. The semantic drift is already present on the shipped tenant dashboard.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Trust The Dashboard At A Glance (Priority: P1)
As a tenant operator, I want the tenant dashboard to tell me within seconds whether governance or findings need action, so that I do not have to reconcile contradictory calm and warning signals across the same page.
**Why this priority**: The tenant dashboard is the main entry point for daily tenant triage. If it feels calmer than reality, every later decision starts from the wrong assumption.
**Independent Test**: Can be fully tested by seeding one tenant with combinations of overdue findings, lapsed governance, compare limitations, and active operations, then verifying that the dashboard surfaces agree on whether the tenant currently needs attention.
**Acceptance Scenarios**:
1. **Given** a tenant has overdue findings or lapsed accepted-risk governance, **When** an entitled operator opens the tenant dashboard, **Then** the page does not present an overall calm or healthy impression.
2. **Given** a tenant has no attention-worthy governance or findings conditions, **When** an entitled operator opens the tenant dashboard, **Then** the dashboard may present calm or healthy signals consistently across the covered summary surfaces.
3. **Given** a tenant has compare trust limitations but few active findings, **When** the tenant dashboard renders, **Then** compare caution remains visible instead of being masked by quieter KPI or recency surfaces.
---
### User Story 2 - Click A Number And Recover The Same Problem (Priority: P1)
As a tenant operator, I want any KPI or attention item I click to land me on a surface that clearly matches the count or problem family I clicked, so that the dashboard feels trustworthy and action-oriented.
**Why this priority**: A tenant-level number that cannot be rediscovered on its target page breaks operator trust immediately.
**Independent Test**: Can be fully tested by clicking representative KPI and attention states in seeded scenarios and verifying that the destination preserves tenant context and exposes the same problem family with recognizable count or label semantics.
**Acceptance Scenarios**:
1. **Given** a dashboard KPI names a findings subset, **When** the operator opens it, **Then** the destination shows that same subset or explicitly states that it is a broader related view.
2. **Given** a tenant dashboard attention item names compare posture, overdue findings, or operations follow-up, **When** the operator opens it, **Then** the destination is the matching tenant-scoped working surface for that problem.
3. **Given** a canonical-view destination is used, **When** the operator arrives from the tenant dashboard, **Then** the destination is already filtered to the originating tenant.
---
### User Story 3 - Separate Posture From Activity And History (Priority: P2)
As a tenant operator, I want the dashboard to distinguish governance posture from live operations and from recent historical lists, so that I can tell what is risky, what is merely running, and what is only recent context.
**Why this priority**: The dashboard should orient action, not flatten risk, activity, and history into one undifferentiated signal layer.
**Independent Test**: Can be fully tested by seeding one tenant with operations-only activity, governance-only issues, and historical-but-complete records, then verifying that each state is surfaced in the right part of the dashboard without diluting the operator's priority order.
**Acceptance Scenarios**:
1. **Given** a tenant has active operations but no current governance problems, **When** the dashboard renders, **Then** the page presents activity without implying governance trouble.
2. **Given** a tenant has active governance problems but no operations currently running, **When** the dashboard renders, **Then** the page prioritizes governance attention rather than recent or idle operations history.
3. **Given** recent lists contain historical successful operations or older drift findings, **When** the dashboard renders, **Then** those lists remain clearly diagnostic and do not override higher-priority attention signals.
### Edge Cases
- A tenant may have zero `new` drift findings while still having overdue active findings, lapsed governance, expiring governance, or other attention-worthy states; the dashboard must still read as action-needed.
- A tenant may have an active operation but otherwise healthy governance posture; the dashboard must show activity without recasting it as tenant risk.
- A tenant may have compare trust limitations, stale compare posture, or missing prerequisites while recent findings and operations look quiet; the compare summary must still prevent a falsely calm overall impression.
- Recent findings or recent operations may include historical or terminal records; those surfaces must not be interpreted as the tenant's primary action queue unless their visible framing says so.
- A user may be entitled to the tenant dashboard but only to a subset of downstream destinations; the dashboard must not expose dead-end drill-throughs that imply broader access than the user actually has. In that case the summary state may remain visible, but the affordance must be disabled or non-clickable with helper text instead of a clickable link that only fails after navigation.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new change operation, and no new long-running process. It hardens the tenant dashboard by aligning existing findings, governance, compare, and operations truth that is already available from current records and current summary logic.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature is intentionally narrow. It adds no new persistence, no new abstraction layer, no new status family, and no new cross-domain taxonomy. The required improvement is truthful alignment of existing summary meaning, not a new tenant-posture architecture.
**Constitution alignment (OPS-UX):** No new `OperationRun` type or execution path is introduced. Existing operation surfaces remain the only source for execution progress and terminal outcome. This slice only changes how tenant-dashboard summaries point to or describe existing operations.
**Constitution alignment (RBAC-UX):** The feature lives in the tenant/admin plane with tenant-context dashboard surfaces and tenant-preserving drill-throughs into existing tenant or canonical admin destinations. Non-members or users without tenant entitlement remain `404`. In-scope members lacking a destination capability remain `403` under the existing enforcement model. Server-side authorization remains the authority for every downstream destination. When an in-scope tenant member can see a dashboard summary state but lacks the downstream capability for its destination, the summary may remain visible but its affordance MUST be disabled or non-clickable with helper text rather than a clickable dead-end drill-through. No new destructive action is introduced.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
**Constitution alignment (BADGE-001):** Existing centralized badge semantics for finding severity, finding status, compare posture, operation status, and operation outcome remain the semantic source. This feature may change where those badge families appear or how they are grouped, but it must not introduce local badge vocabularies for dashboard calm, caution, or attention claims.
**Constitution alignment (UI-FIL-001):** The feature reuses existing Filament dashboard widgets, stats, cards, table widgets, links, and shared badge primitives. It should avoid custom local markup for dashboard truth emphasis and instead rely on aligned copy, aligned counts, and existing visual primitives.
**Constitution alignment (UI-NAMING-001):** The target objects are tenant dashboard KPI counts, attention items, compare-posture claims, and operations activity claims. Primary operator-facing vocabulary remains `Open drift findings`, `High severity`, `Needs attention`, `Baseline compare`, `Operations`, and related domain nouns. If a dashboard metric intentionally becomes narrower or broader than another surface, the naming must make that difference visible instead of leaving two similarly named signals to mean different things.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** Each affected tenant dashboard surface must have exactly one primary inspect or drill-through model. KPI stats use one matching destination per metric. Attention items use one matching destination or one explicit next step per item. `BaselineCompareNow` uses one aligned next-action model. `RecentDriftFindings` and `RecentOperations` keep row-click inspection as diagnostic recency surfaces. No destructive action is introduced, and no redundant view affordance is required.
**Constitution alignment (OPSURF-001):** The dashboard must stay operator-first. Default-visible content must separate governance posture, compare trust, operations activity, and recency instead of collapsing them into one ambiguous signal. Diagnostics remain on downstream pages. Mutation scope remains none for the dashboard itself.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct per-widget interpretation has already drifted into contradictory dashboard meaning. This feature must reduce duplicate local ownership of count meaning and calm-claim guards, but it must do so by aligning existing definitions rather than adding a new presentation framework. Tests must focus on business truth across widgets and drill-throughs.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. The tenant dashboard page and its widgets remain inspection and drill-through surfaces with exactly one primary inspect or open model per surface, no new destructive actions, no empty action groups, and no redundant `View` buttons. UI-FIL-001 is satisfied and no exception is required.
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature does not add new create or edit screens. It refines existing dashboard summary and table surfaces. The dashboard must keep high-priority attention and compare posture above recency context, and existing table widgets must continue to use explicit headings, empty states, and current table affordances without pretending to be the dashboard's primary posture layer.
### Functional Requirements
- **FR-173-001**: Tenant dashboard surfaces that make findings-related summary claims MUST use a consistent active-findings meaning across `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, and their related destination copy, unless a deliberately narrower subset is clearly named as such.
- **FR-173-002**: Tenant dashboard high-severity claims MUST use the same severity and activity meaning across KPI and attention surfaces, or visibly different labels that tell the operator they are not the same subset.
- **FR-173-003**: Positive, calm, healthy, or trustworthy tenant-level claims MUST be suppressed whenever the same tenant currently has attention-worthy overdue findings, lapsed accepted-risk governance, expiring governance requiring near-term follow-up, high-severity active findings, materially degraded compare posture, or materially relevant operations problems.
- **FR-173-004**: The overall tenant dashboard MUST NOT appear healthier than the strongest attention-worthy tenant condition visible on the page.
- **FR-173-005**: Every tenant dashboard KPI or attention item that names a non-zero count or actionable problem family MUST resolve to one semantically matching destination surface where the operator can recognize the same problem family without guesswork. Intentionally passive reassurance or no-assignment empty states may remain non-interactive when their copy does not imply a recoverable work queue.
- **FR-173-006**: When the matching destination is a canonical-view route, opening it from the tenant dashboard MUST preserve tenant context through a pre-applied tenant filter.
- **FR-173-007**: `NeedsAttention` MUST cover at least overdue findings, lapsed governance, expiring governance, high-severity active findings, baseline compare posture limitations, and materially relevant operations follow-up when those states exist. For this slice, materially relevant operations follow-up means tenant-scoped runs whose current status or outcome indicates failure, warning, or unusually long-running or stalled execution that requires operator review; healthy queued or running activity alone remains an activity signal, not a governance-risk signal.
- **FR-173-008**: Central `NeedsAttention` items MUST be actionable. They MUST either open the matching target surface directly or expose one explicit next step that sends the operator toward that surface. If the current in-scope user lacks the destination capability, the item may remain visible only as a disabled or non-clickable explanatory state and MUST NOT render as a clickable dead-end drill-through.
- **FR-173-009**: Tenant dashboard summary surfaces MUST distinguish governance posture from operations activity. Active operations MUST NOT be presented as though they are themselves governance health outcomes.
- **FR-173-010**: `RecentDriftFindings` and `RecentOperations` MUST remain visibly diagnostic or recency-oriented surfaces and MUST NOT dilute the tenant's priority order when higher-priority attention conditions exist elsewhere on the dashboard.
- **FR-173-011**: `BaselineCompareNow` MUST not present a positive or trustworthy posture when the same tenant dashboard already contains unresolved caution or attention conditions that materially limit that claim.
- **FR-173-012**: Dashboard count meaning, calm-claim guards, and drill-through continuity MUST be aligned using existing findings truth, existing governance truth, existing compare assessment logic, and existing destination pages rather than a new persisted dashboard aggregate.
- **FR-173-013**: The feature MUST stay bounded to the existing tenant dashboard surface family and MUST NOT require a new global tenant-posture indicator, a dashboard layout rebuild, or a new schema artifact.
- **FR-173-014**: Regression coverage MUST exercise cross-widget consistency for active findings, high severity, overdue, lapsed, or expiring governance, compare limitations, healthy operations-only activity, attention-worthy operations follow-up, and KPI or attention drill-through continuity.
## 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 page composition | `app/Filament/Pages/TenantDashboard.php` | Existing dashboard page header actions remain unchanged | n/a | n/a | n/a | n/a | n/a | n/a | no new audit behavior | Composition-only surface that must keep the current priority order of KPI, attention, compare, then recency |
| `DashboardKpis` | `app/Filament/Widgets/Dashboard/DashboardKpis.php` | none | Explicit stat click for each actionable or non-zero KPI | none | none | Existing zero-state stats remain intentionally passive reassurance | n/a | n/a | no new audit behavior | Each KPI must keep one matching destination and honest subset naming |
| `NeedsAttention` | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | none | One explicit destination or next-step affordance per attention item | none | none | Healthy fallback remains read-only reassurance only when no attention condition exists | n/a | n/a | no new audit behavior | Surface currently acts as summary-only; this spec requires central items to become action-capable |
| `BaselineCompareNow` | `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` | none | One aligned primary CTA based on compare posture or next action when compare work exists | none | none | Existing no-assignment state remains an intentionally passive summary state | n/a | n/a | no new audit behavior | Supportive links may remain only if they reinforce the same compare truth |
| `RecentDriftFindings` | `app/Filament/Widgets/Dashboard/RecentDriftFindings.php` | none | `recordUrl()` row click to finding detail | none | none | Existing empty state remains diagnostic, not posture-defining | n/a | n/a | no new audit behavior | Recency surface must not be mistaken for the full active findings queue |
| `RecentOperations` | `app/Filament/Widgets/Dashboard/RecentOperations.php` | none | `recordUrl()` row click to operation detail | none | none | Existing empty state remains diagnostic, not posture-defining | n/a | n/a | no new audit behavior | Canonical operations list remains the collection destination and must preserve tenant filter when reached from the dashboard |
### Key Entities *(include if feature involves data)*
- **Tenant dashboard summary signal**: A tenant-level count, posture claim, or attention message that compresses findings, governance, compare, or operations truth into one visible dashboard indicator.
- **Attention item**: A tenant-level named problem that should point to one matching working surface or one explicit next step.
- **Materially relevant operations follow-up**: A tenant-scoped operations condition that requires operator review because the current run state indicates failure, warning, or unusually long-running or stalled execution; healthy queued or running activity alone is not enough.
- **Drill-through contract**: The semantic promise that a dashboard number or message can be recognized again on the surface it opens.
- **Recent diagnostic surface**: A recency-oriented list that provides historical or active context without redefining the tenant's current governance posture.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-173-001**: In regression coverage, every tested tenant scenario with overdue findings, lapsed or expiring governance, compare limitations, high-severity active findings, or attention-worthy operations follow-up produces no combination of dashboard summary signals that reads as an all-clear.
- **SC-173-002**: In regression coverage, covered KPI and attention drill-throughs land on destinations whose visible framing and filtered result set match the originating problem family in 100% of tested scenarios.
- **SC-173-003**: In operator review on seeded tenants, an operator can determine within 10 seconds whether the tenant currently has governance attention, compare caution, operations-only activity, or no immediate action required.
- **SC-173-004**: In regression coverage, operations-only scenarios do not trigger governance-problem wording, and governance-problem scenarios are not diluted by recent-history surfaces.
- **SC-173-005**: The feature ships without a required schema migration, a new persisted dashboard summary record, or a new global tenant-posture component.
## Assumptions
- Existing tenant findings surfaces, baseline compare landing, and canonical operations routes remain the correct downstream destinations for dashboard drill-throughs.
- Existing findings governance and compare-assessment logic are sufficient to align dashboard truth without introducing a new persisted dashboard aggregate.
- The current tenant dashboard surface set remains in place for this slice; the work changes truth alignment, not the overall page structure.
## Non-Goals
- Introducing a new global tenant-posture indicator or composite dashboard hero state
- Rebuilding the tenant dashboard layout or replacing the current five-surface composition
- Creating new tables, new persisted summary entities, or new findings-status families
- Rewriting workspace-level governance surfacing or the broader navigation and filter framework
- Treating recency tables as a new primary work queue beyond the targeted truth-alignment improvements
## Dependencies
- Existing `TenantDashboard` composition and current dashboard widgets
- Existing findings workflow and governance hardening work, especially tenant findings and accepted-risk governance truth
- Existing baseline compare summary and trust logic used by tenant compare surfaces
- Existing operations surface alignment and canonical operation drill-through behavior
## Follow-up Spec Candidates
- **Tenant Posture Indicator & Overview Hierarchy** for a future composite tenant posture layer once summary truth is aligned
- **Workspace Governance Posture Surfacing** for a later workspace-level summary that builds on tenant-safe tenant-posture truth
- **Recent Surface Queue/Diagnostic Separation** for a later sharper distinction between diagnostic recency lists and operator work queues
## Definition of Done
Spec 173 is complete when:
- tenant dashboard KPI, attention, compare, and recent surfaces no longer make contradictory governance or findings claims for the same tenant,
- the tenant dashboard no longer appears calmer than the underlying tenant truth,
- covered KPI and attention signals are semantically recoverable on their destinations,
- operations activity and governance posture are more clearly separated,
- positive compare or calm dashboard claims are sufficiently guarded by the same tenant-level risks that drive attention,
- and the improvement is achieved without a new persisted summary model or a full dashboard redesign.

View File

@ -0,0 +1,215 @@
# Tasks: Tenant Dashboard KPI & Attention Truth Alignment
**Input**: Design documents from `/specs/173-tenant-dashboard-truth-alignment/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature. Use Pest coverage in `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Findings/FindingsListDefaultsTest.php`, `tests/Feature/Findings/FindingsListFiltersTest.php`, `tests/Feature/Findings/FindingAdminTenantParityTest.php`, `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`, `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`, `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/TableStandardsBaselineTest.php`, and `tests/Feature/Filament/TableDetailVisibilityTest.php`.
**Operations**: This feature does not create a new `OperationRun` type or change run lifecycle ownership. Existing canonical Operations routes remain the only operations destinations involved, and the work here is limited to tenant-prefilter continuity and operator-facing summary truth.
**RBAC**: Existing tenant-context membership, entitlement, and 404 vs 403 semantics remain unchanged. Tasks must preserve tenant-safe dashboard destinations and ensure canonical Operations drill-throughs remain filtered to the originating tenant when entered from tenant context.
**Operator Surfaces**: `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, `RecentDriftFindings`, and `RecentOperations` must stay operator-first, with governance posture and compare caution above recency context.
**Filament UI Action Surfaces**: No new destructive actions or redundant inspect affordances are added. `DashboardKpis` and `NeedsAttention` remain drill-through summary surfaces, `BaselineCompareNow` remains a single-next-step compare surface, and `RecentDriftFindings` and `RecentOperations` remain row-click diagnostic tables.
**Filament UI UX-001**: No new create, edit, or view pages are introduced. Existing dashboard summary and table widgets keep their current layout while truth alignment and destination continuity are hardened.
**Badges**: Existing badge semantics for finding severity, finding status, compare posture, operation status, and operation outcome remain authoritative; no new page-local mappings are introduced.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment.
## Phase 1: Setup (Dashboard Truth Test Scaffolding)
**Purpose**: Create the focused regression files and fixtures needed to implement Spec 173 safely.
- [X] T001 Create dashboard truth-alignment regression scaffolding in `tests/Feature/Filament/DashboardKpisWidgetTest.php` and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
- [X] T002 [P] Create canonical operations drill-through regression scaffolding in `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`
---
## Phase 2: Foundational (Blocking Destination and Semantics Helpers)
**Purpose**: Establish the canonical findings and operations drill-through semantics that all stories depend on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 Add destination-side continuity and permission-limited affordance assertions for dashboard-driven findings and operations filters in `tests/Feature/Findings/FindingsListFiltersTest.php` and `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`
- [X] T004 [P] Implement reusable findings subset semantics for dashboard links in `app/Models/Finding.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
- [X] T005 [P] Implement tenant-prefiltered canonical Operations collection links in `app/Support/OperationRunLinks.php` and `app/Filament/Pages/Monitoring/Operations.php`
**Checkpoint**: Dashboard destinations now have one canonical findings subset model and one tenant-safe canonical Operations entry path.
---
## Phase 3: User Story 1 - Trust The Dashboard At A Glance (Priority: P1) 🎯 MVP
**Goal**: Make the tenant dashboard read honestly at a glance so it never looks calmer than the tenant's actual governance and findings state.
**Independent Test**: Seed one tenant with overdue findings, lapsed or expiring governance, compare limitations, attention-worthy operations follow-up, and healthy all-clear states, then verify the dashboard summary surfaces do not emit contradictory calm and warning signals.
### Tests for User Story 1
- [X] T006 [P] [US1] Add KPI truth, no-all-clear regression cases, and permission-limited KPI affordance coverage in `tests/Feature/Filament/DashboardKpisWidgetTest.php`
- [X] T007 [P] [US1] Add full-dashboard calmness suppression scenarios for overdue, lapsed, or expiring governance, compare limitations, and attention-worthy operations follow-up in `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
### Implementation for User Story 1
- [X] T008 [US1] Align KPI labels and count universes with canonical active/high-severity semantics and disabled/non-clickable fallback behavior in `app/Filament/Widgets/Dashboard/DashboardKpis.php`
- [X] T009 [US1] Gate healthy fallback and attention wording against aggregate-backed lapsed or expiring governance, severity truth, and defined operations follow-up in `app/Filament/Widgets/Dashboard/NeedsAttention.php` and `resources/views/filament/widgets/dashboard/needs-attention.blade.php`
- [X] T010 [US1] Keep compare calmness and baseline-compare landing next-step continuity aligned with tenant attention conditions from the existing compare assessment and governance aggregate outputs in `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` and `resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php`
- [X] T011 [US1] Run focused US1 verification against `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, and `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`
**Checkpoint**: The dashboard no longer presents an all-clear when overdue, lapsed, or compare-limited tenant states exist.
---
## Phase 4: User Story 2 - Click A Number And Recover The Same Problem (Priority: P1)
**Goal**: Make KPI and attention drill-throughs land on destinations where the same problem family is immediately recognizable.
**Independent Test**: Click representative KPI and attention states for findings, compare posture, and operations activity, then verify the destination preserves tenant context and exposes the same problem family through filters, tabs, or explicit framing, or renders a disabled or non-clickable explanatory state when the member lacks destination capability.
### Tests for User Story 2
- [X] T012 [P] [US2] Add findings drill-through continuity cases and permission-limited KPI fallback cases in `tests/Feature/Filament/DashboardKpisWidgetTest.php` and `tests/Feature/Findings/FindingsListFiltersTest.php`
- [X] T013 [P] [US2] Add high-severity attention, baseline-compare landing, and Operations drill-through continuity cases, including permission-limited disabled or non-clickable states, in `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
### Implementation for User Story 2
- [X] T014 [US2] Implement findings KPI URLs that reproduce named subsets through tabs and filters, with disabled or non-clickable fallback when destination capability is missing, in `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
- [X] T015 [US2] Add one tenant-safe primary action per central attention item, including high-severity active-findings coverage, baseline-compare landing continuity, and disabled or non-clickable helper text when destination capability is missing, in `app/Filament/Widgets/Dashboard/NeedsAttention.php` and `resources/views/filament/widgets/dashboard/needs-attention.blade.php`
- [X] T016 [US2] Preserve tenant context for dashboard Operations drill-throughs in `app/Support/OperationRunLinks.php` and `app/Filament/Pages/Monitoring/Operations.php`
- [X] T017 [US2] Run focused US2 verification against `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, and `tests/Feature/Findings/FindingsListFiltersTest.php`
**Checkpoint**: KPI and attention signals now open tenant-safe findings, baseline-compare, and operations destinations that clearly match the originating problem family.
---
## Phase 5: User Story 3 - Separate Posture From Activity And History (Priority: P2)
**Goal**: Keep governance posture, operations activity, and recent diagnostic history visibly distinct so the dashboard remains a priority surface rather than a mixed feed.
**Independent Test**: Seed one tenant with healthy operations-only activity, failed, warning, stalled, or unusually long-running operations follow-up, governance-only issues, and recent successful history, then verify the dashboard preserves posture-first attention while leaving recent tables diagnostic.
### Tests for User Story 3
- [X] T018 [P] [US3] Add healthy operations-only, failed, warning, stalled, or unusually long-running follow-up, and recency-does-not-override-posture scenarios in `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
- [X] T019 [P] [US3] Add diagnostic-surface safeguards and row-click detail continuity checks for recent tables in `tests/Feature/Filament/TenantDashboardDbOnlyTest.php` and `tests/Feature/Filament/TableDetailVisibilityTest.php`
### Implementation for User Story 3
- [X] T020 [US3] Separate healthy operations activity wording from governance posture and map failed, warning, stalled, or unusually long-running runs to explicit follow-up attention in `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Widgets/Dashboard/NeedsAttention.php`
- [X] T021 [US3] Preserve diagnostic-only framing and existing full-row detail navigation for recent tables in `app/Filament/Widgets/Dashboard/RecentDriftFindings.php` and `app/Filament/Widgets/Dashboard/RecentOperations.php`
- [X] T022 [US3] Run focused US3 verification against `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/TableStandardsBaselineTest.php`, and `tests/Feature/Filament/TableDetailVisibilityTest.php`
**Checkpoint**: The dashboard now distinguishes what is risky, what is merely running, and what is only recent context.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finish copy alignment, formatting, and the focused verification pack across all stories.
- [X] T023 [P] Align operator-facing dashboard copy for the 10-second operator scan, operations follow-up wording, and permission-limited helper text in `app/Filament/Widgets/Dashboard/DashboardKpis.php`, `app/Filament/Widgets/Dashboard/NeedsAttention.php`, `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`, `resources/views/filament/widgets/dashboard/needs-attention.blade.php`, and `resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php`
- [X] T024 Run formatting with `vendor/bin/sail bin pint --dirty --format agent` using `specs/173-tenant-dashboard-truth-alignment/quickstart.md`
- [X] T025 Run the final verification pack from `specs/173-tenant-dashboard-truth-alignment/quickstart.md` against `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Findings/FindingsListDefaultsTest.php`, `tests/Feature/Findings/FindingsListFiltersTest.php`, `tests/Feature/Findings/FindingAdminTenantParityTest.php`, `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`, `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`, `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/TableStandardsBaselineTest.php`, and `tests/Feature/Filament/TableDetailVisibilityTest.php`
- [X] T026 Run the timed operator-comprehension smoke check from `specs/173-tenant-dashboard-truth-alignment/quickstart.md` on seeded tenants, including a permission-limited member scenario
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and creates the new regression files for this slice.
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until destination semantics are canonicalized.
- **User Stories (Phase 3+)**: All depend on Foundational completion.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Foundational completion and delivers the MVP truth-alignment slice.
- **User Story 2 (P1)**: Can start after Foundational completion and should remain independently testable, though it touches some of the same dashboard widgets as US1.
- **User Story 3 (P2)**: Can start after Foundational completion and remains independently testable, though it overlaps with the same dashboard surface family.
### Within Each User Story
- Story tests should be written before or alongside the implementation tasks and must fail before the story is considered complete.
- Destination helper changes should land before the focused story-level verification run.
- Widget logic should land before any view-copy refinements tied to the same story.
### Parallel Opportunities
- `T001` and `T002` can run in parallel during Setup.
- `T004` and `T005` can run in parallel during Foundational work.
- `T006` and `T007` can run in parallel for User Story 1.
- `T012` and `T013` can run in parallel for User Story 2.
- `T018` and `T019` can run in parallel for User Story 3.
- `T023` can run while final verification commands are being prepared.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel:
Task: T006 tests/Feature/Filament/DashboardKpisWidgetTest.php
Task: T007 tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
# User Story 1 implementation split after foundational semantics are in place:
Task: T008 app/Filament/Widgets/Dashboard/DashboardKpis.php
Task: T010 app/Filament/Widgets/Dashboard/BaselineCompareNow.php and resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel:
Task: T012 tests/Feature/Filament/DashboardKpisWidgetTest.php and tests/Feature/Findings/FindingsListFiltersTest.php
Task: T013 tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php and tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
# User Story 2 implementation split after tests exist:
Task: T015 app/Filament/Widgets/Dashboard/NeedsAttention.php and resources/views/filament/widgets/dashboard/needs-attention.blade.php
Task: T016 app/Support/OperationRunLinks.php and app/Filament/Pages/Monitoring/Operations.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 tests in parallel:
Task: T018 tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
Task: T019 tests/Feature/Filament/TenantDashboardDbOnlyTest.php and tests/Feature/Filament/TableDetailVisibilityTest.php
# User Story 3 implementation split after posture/activity requirements are settled:
Task: T020 app/Filament/Widgets/Dashboard/DashboardKpis.php and app/Filament/Widgets/Dashboard/NeedsAttention.php
Task: T021 app/Filament/Widgets/Dashboard/RecentDriftFindings.php and app/Filament/Widgets/Dashboard/RecentOperations.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. **STOP and VALIDATE**: Verify the dashboard no longer emits false calm when tenant attention conditions exist.
### Incremental Delivery
1. Complete Setup + Foundational to lock down canonical destination semantics.
2. Deliver User Story 1 as the MVP truth-alignment slice.
3. Add User Story 2 for drill-through continuity.
4. Add User Story 3 for posture/activity/recency separation.
5. Finish with polish, formatting, the timed operator smoke check, and the final focused verification pack.
### Parallel Team Strategy
1. One developer can prepare the new regression files while another hardens canonical findings and Operations destination helpers.
2. After Foundational work is complete, one developer can focus on KPI and compare calmness while another handles `NeedsAttention` actionability and Operations drill-through continuity.
3. Rejoin for story-level verification and final polish because several stories touch the same dashboard widget family.
---
## Notes
- `[P]` tasks target different files or safe concurrent work after foundational semantics are in place.
- `[US1]`, `[US2]`, and `[US3]` labels map tasks directly to the feature specification user stories.
- 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 panel/provider registration, or a new destructive action.

View File

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Rbac\UiTooltips;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
/**
* @return array<string, array{value:string,description:string|null,url:string|null}>
*/
function dashboardKpiStatPayloads($component): array
{
$method = new ReflectionMethod(DashboardKpis::class, 'getStats');
$method->setAccessible(true);
return collect($method->invoke($component->instance()))
->mapWithKeys(fn (Stat $stat): array => [
(string) $stat->getLabel() => [
'value' => (string) $stat->getValue(),
'description' => $stat->getDescription(),
'url' => $stat->getUrl(),
],
])
->all();
}
it('aligns dashboard KPI counts and drill-throughs to canonical findings and operations semantics', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'status' => Finding::STATUS_NEW,
'severity' => Finding::SEVERITY_LOW,
]);
Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'status' => Finding::STATUS_TRIAGED,
'severity' => Finding::SEVERITY_MEDIUM,
]);
Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'status' => Finding::STATUS_REOPENED,
'severity' => Finding::SEVERITY_CRITICAL,
]);
Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_PERMISSION_POSTURE,
'status' => Finding::STATUS_IN_PROGRESS,
'severity' => Finding::SEVERITY_HIGH,
]);
Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'status' => Finding::STATUS_RESOLVED,
'severity' => Finding::SEVERITY_HIGH,
]);
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()->subMinute(),
]);
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::PartiallySucceeded->value,
]);
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);
$stats = dashboardKpiStatPayloads(Livewire::test(DashboardKpis::class));
expect($stats)->toMatchArray([
'Open drift findings' => [
'value' => '3',
'description' => 'active drift workflow items',
'url' => FindingResource::getUrl('index', [
'tab' => 'needs_action',
'finding_type' => Finding::FINDING_TYPE_DRIFT,
], panel: 'tenant', tenant: $tenant),
],
'High severity active findings' => [
'value' => '2',
'description' => 'high or critical findings needing review',
'url' => FindingResource::getUrl('index', [
'tab' => 'needs_action',
'high_severity' => 1,
], panel: 'tenant', tenant: $tenant),
],
'Active operations' => [
'value' => '1',
'description' => 'healthy queued or running tenant work',
'url' => OperationRunLinks::index($tenant, activeTab: 'active'),
],
'Operations needing follow-up' => [
'value' => '3',
'description' => 'failed, warning, or stalled runs',
'url' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
],
]);
});
it('keeps findings KPI truth visible while disabling dead-end drill-throughs for members without findings access', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'status' => Finding::STATUS_NEW,
'severity' => Finding::SEVERITY_CRITICAL,
]);
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, fn (): bool => false);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
$stats = dashboardKpiStatPayloads(Livewire::test(DashboardKpis::class));
expect($stats['Open drift findings'])->toMatchArray([
'value' => '1',
'description' => UiTooltips::INSUFFICIENT_PERMISSION,
'url' => null,
]);
expect($stats['High severity active findings'])->toMatchArray([
'value' => '1',
'description' => UiTooltips::INSUFFICIENT_PERMISSION,
'url' => null,
]);
});

View File

@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use App\Filament\Widgets\Dashboard\NeedsAttention; use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
@ -9,11 +10,14 @@
use App\Models\Finding; use App\Models\Finding;
use App\Models\FindingException; use App\Models\FindingException;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\Rbac\UiTooltips;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire; use Livewire\Livewire;
function createNeedsAttentionTenant(): array function createNeedsAttentionTenant(): array
@ -79,13 +83,13 @@ function createNeedsAttentionTenant(): array
->assertSee('Needs Attention') ->assertSee('Needs Attention')
->assertSee('Baseline compare posture') ->assertSee('Baseline compare posture')
->assertSee('The last compare finished, but normal result output was suppressed.') ->assertSee('The last compare finished, but normal result output was suppressed.')
->assertSee('Review compare detail') ->assertSee('Open Baseline Compare')
->assertDontSee('Current dashboard signals look trustworthy.'); ->assertDontSee('Current governance and findings signals look trustworthy.');
expect($component->html())->not->toContain('href='); expect($component->html())->toContain('href=');
}); });
it('keeps needs-attention non-navigational and healthy only for trustworthy compare results', function (): void { it('keeps needs-attention healthy only for trustworthy compare results', function (): void {
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant(); [$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
$this->actingAs($user); $this->actingAs($user);
@ -115,7 +119,7 @@ function createNeedsAttentionTenant(): array
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
$component = Livewire::test(NeedsAttention::class) $component = Livewire::test(NeedsAttention::class)
->assertSee('Current dashboard signals look trustworthy.') ->assertSee('Current governance and findings signals look trustworthy.')
->assertSee('Baseline compare looks trustworthy') ->assertSee('Baseline compare looks trustworthy')
->assertSee('No confirmed drift in the latest baseline compare.') ->assertSee('No confirmed drift in the latest baseline compare.')
->assertDontSee('Baseline compare posture'); ->assertDontSee('Baseline compare posture');
@ -156,7 +160,7 @@ function createNeedsAttentionTenant(): array
->assertSee('Baseline compare posture') ->assertSee('Baseline compare posture')
->assertSee('The latest baseline compare result is stale.') ->assertSee('The latest baseline compare result is stale.')
->assertSee('Open Baseline Compare') ->assertSee('Open Baseline Compare')
->assertDontSee('Current dashboard signals look trustworthy.'); ->assertDontSee('Current governance and findings signals look trustworthy.');
}); });
it('surfaces compare unavailability instead of a healthy fallback when no result exists yet', function (): void { it('surfaces compare unavailability instead of a healthy fallback when no result exists yet', function (): void {
@ -170,7 +174,7 @@ function createNeedsAttentionTenant(): array
->assertSee('Baseline compare posture') ->assertSee('Baseline compare posture')
->assertSee('A current baseline compare result is not available yet.') ->assertSee('A current baseline compare result is not available yet.')
->assertSee('Open Baseline Compare') ->assertSee('Open Baseline Compare')
->assertDontSee('Current dashboard signals look trustworthy.'); ->assertDontSee('Current governance and findings signals look trustworthy.');
}); });
it('surfaces overdue and lapsed-governance findings even when there are no new findings', function (): void { it('surfaces overdue and lapsed-governance findings even when there are no new findings', function (): void {
@ -192,10 +196,11 @@ function createNeedsAttentionTenant(): array
Livewire::test(NeedsAttention::class) Livewire::test(NeedsAttention::class)
->assertSee('Overdue findings') ->assertSee('Overdue findings')
->assertSee('Lapsed accepted-risk governance') ->assertSee('Lapsed accepted-risk governance')
->assertDontSee('Current dashboard signals look trustworthy.'); ->assertSee('Open findings')
->assertDontSee('Current governance and findings signals look trustworthy.');
}); });
it('surfaces expiring governance from the shared aggregate without adding navigation links', function (): void { it('surfaces expiring governance from the shared aggregate with the matching findings action', function (): void {
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant(); [$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
$this->actingAs($user); $this->actingAs($user);
@ -251,7 +256,31 @@ function createNeedsAttentionTenant(): array
$component = Livewire::test(NeedsAttention::class) $component = Livewire::test(NeedsAttention::class)
->assertSee('Expiring accepted-risk governance') ->assertSee('Expiring accepted-risk governance')
->assertSee('Open findings') ->assertSee('Open findings')
->assertDontSee('Current dashboard signals look trustworthy.'); ->assertDontSee('Current governance and findings signals look trustworthy.');
expect($component->html())->not->toContain('href='); expect($component->html())->toContain('href=');
});
it('keeps findings attention visible but non-clickable when the member lacks findings access', function (): void {
[$user, $tenant] = createNeedsAttentionTenant();
$this->actingAs($user);
Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_TRIAGED,
'due_at' => now()->subDay(),
]);
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, fn (): bool => false);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
$component = Livewire::test(NeedsAttention::class)
->assertSee('Overdue findings')
->assertSee('Open findings')
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION);
expect($component->html())
->not->toContain(FindingResource::getUrl('index', ['tab' => 'overdue'], panel: 'tenant', tenant: $tenant))
->toContain('Open Baseline Compare');
}); });

View File

@ -4,15 +4,23 @@
use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\InventoryCoverage;
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups; use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
use App\Filament\Resources\FindingResource;
use App\Filament\System\Pages\Directory\Workspaces; use App\Filament\System\Pages\Directory\Workspaces;
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
use App\Filament\Widgets\Dashboard\RecentOperations; use App\Filament\Widgets\Dashboard\RecentOperations;
use App\Livewire\EntraGroupCachePickerTable; use App\Livewire\EntraGroupCachePickerTable;
use App\Livewire\SettingsCatalogSettingsTable; use App\Livewire\SettingsCatalogSettingsTable;
use App\Models\EntraGroup; use App\Models\EntraGroup;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\Workspace; use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -126,7 +134,15 @@ function spec125DetailPlatformContext(): PlatformUser
}); });
it('keeps dashboard widgets as glance surfaces instead of searchable investigative tables', function (): void { it('keeps dashboard widgets as glance surfaces instead of searchable investigative tables', function (): void {
[$user] = spec125DetailTenantContext(); [$user, $tenant] = spec125DetailTenantContext();
$operation = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$component = Livewire::actingAs($user)->test(RecentOperations::class); $component = Livewire::actingAs($user)->test(RecentOperations::class);
$table = spec125DetailTable($component); $table = spec125DetailTable($component);
@ -142,6 +158,7 @@ function spec125DetailPlatformContext(): PlatformUser
expect($table->getColumn('created_at')?->isSortable())->toBeTrue(); expect($table->getColumn('created_at')?->isSortable())->toBeTrue();
expect($table->getColumn('status')?->isToggleable())->toBeTrue(); expect($table->getColumn('status')?->isToggleable())->toBeTrue();
expect($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getRecordUrl($operation))->toBe(OperationRunLinks::view($operation, $tenant));
expect(array_keys($table->getVisibleColumns()))->toBe([ expect(array_keys($table->getVisibleColumns()))->toBe([
'short_id', 'short_id',
'type', 'type',
@ -150,6 +167,23 @@ function spec125DetailPlatformContext(): PlatformUser
]); ]);
}); });
it('keeps recent drift findings row clicks on the canonical finding detail view', function (): void {
[$user, $tenant] = spec125DetailTenantContext();
$finding = Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
]);
$component = Livewire::actingAs($user)->test(RecentDriftFindings::class);
$table = spec125DetailTable($component);
expect($table->getRecordUrl($finding))->toBe(
FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)
);
});
it('keeps picker tables workflow-local while preserving readable hidden troubleshooting detail', function (): void { it('keeps picker tables workflow-local while preserving readable hidden troubleshooting detail', function (): void {
[$user, $tenant] = spec125DetailTenantContext(); [$user, $tenant] = spec125DetailTenantContext();

View File

@ -3,11 +3,13 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\InventoryCoverage;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies; use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy; use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Filament\Widgets\Dashboard\RecentDriftFindings; use App\Filament\Widgets\Dashboard\RecentDriftFindings;
use App\Livewire\EntraGroupCachePickerTable; use App\Livewire\EntraGroupCachePickerTable;
use App\Models\Finding;
use App\Models\Policy; use App\Models\Policy;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -93,7 +95,13 @@ function spec125BaselineTenantContext(): array
}); });
it('keeps the dashboard widget profile minimal and scan-first', function (): void { it('keeps the dashboard widget profile minimal and scan-first', function (): void {
[$user] = spec125BaselineTenantContext(); [$user, $tenant] = spec125BaselineTenantContext();
$finding = Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
]);
$component = Livewire::actingAs($user)->test(RecentDriftFindings::class); $component = Livewire::actingAs($user)->test(RecentDriftFindings::class);
$table = spec125BaselineTable($component); $table = spec125BaselineTable($component);
@ -113,6 +121,9 @@ function spec125BaselineTenantContext(): array
expect($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('severity')?->isSortable())->toBeTrue(); expect($table->getColumn('severity')?->isSortable())->toBeTrue();
expect($table->getColumn('created_at')?->isSortable())->toBeTrue(); expect($table->getColumn('created_at')?->isSortable())->toBeTrue();
expect($table->getRecordUrl($finding))->toBe(
FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)
);
}); });
it('keeps custom table pages on their explicit profile without hidden framework defaults', function (): void { it('keeps custom table pages on their explicit profile without hidden framework defaults', function (): void {

View File

@ -44,7 +44,7 @@
Livewire::test(DashboardKpis::class) Livewire::test(DashboardKpis::class)
->assertSee('Active operations') ->assertSee('Active operations')
->assertSee('backup, sync & compare operations'); ->assertSee('healthy queued or running tenant work');
Livewire::test(DashboardRecentOperations::class) Livewire::test(DashboardRecentOperations::class)
->assertSee('Operation ID') ->assertSee('Operation ID')

View File

@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Filament\Facades\Filament;
use Livewire\Livewire;
function createTruthAlignedDashboardTenant(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Baseline A',
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
return [$user, $tenant, $profile, $snapshot];
}
function seedTrustworthyCompare(array $tenantContext): void
{
[$user, $tenant, $profile, $snapshot] = $tenantContext;
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subHour(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
],
],
]);
}
it('suppresses calm dashboard wording when operations follow-up still exists', function (): void {
$tenantContext = createTruthAlignedDashboardTenant();
[$user, $tenant] = $tenantContext;
$this->actingAs($user);
seedTrustworthyCompare($tenantContext);
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(),
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class)
->assertSee('Operations need follow-up')
->assertSee('Open operations')
->assertDontSee('Current governance and findings signals look trustworthy.');
Livewire::test(BaselineCompareNow::class)
->assertSee('Action required')
->assertSee('operation')
->assertSee('Open operations')
->assertDontSee('Aligned');
});
it('suppresses compare calmness when high-severity active findings remain open', function (): void {
$tenantContext = createTruthAlignedDashboardTenant();
[$user, $tenant] = $tenantContext;
$this->actingAs($user);
seedTrustworthyCompare($tenantContext);
Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_TRIAGED,
'severity' => Finding::SEVERITY_CRITICAL,
'finding_type' => Finding::FINDING_TYPE_PERMISSION_POSTURE,
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class)
->assertSee('High severity active findings')
->assertSee('Open findings')
->assertDontSee('Current governance and findings signals look trustworthy.');
Livewire::test(BaselineCompareNow::class)
->assertSee('Action required')
->assertSee('high-severity active finding')
->assertSee('Open findings')
->assertDontSee('Aligned');
});
it('keeps healthy operations-only activity separate from governance attention', function (): void {
$tenantContext = createTruthAlignedDashboardTenant();
[$user, $tenant] = $tenantContext;
$this->actingAs($user);
seedTrustworthyCompare($tenantContext);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinute(),
'started_at' => now()->subMinute(),
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class)
->assertSee('Current governance and findings signals look trustworthy.')
->assertSee('Operations are active')
->assertDontSee('Operations need follow-up');
Livewire::test(BaselineCompareNow::class)
->assertSee('Aligned')
->assertSee('No action needed')
->assertDontSee('Action required');
});
it('keeps overdue and governance-lapsed attention actionable without falling back to calm wording', function (): void {
$tenantContext = createTruthAlignedDashboardTenant();
[$user, $tenant] = $tenantContext;
$this->actingAs($user);
seedTrustworthyCompare($tenantContext);
Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_TRIAGED,
'due_at' => now()->subDay(),
]);
$lapsedFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $lapsedFinding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'approved_by_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Lapsed governance',
'approval_reason' => 'Approved',
'requested_at' => now()->subDays(5),
'approved_at' => now()->subDays(4),
'effective_from' => now()->subDays(4),
'review_due_at' => now()->subDay(),
'expires_at' => now()->subDay(),
'evidence_summary' => ['reference_count' => 0],
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class)
->assertSee('Overdue findings')
->assertSee('Lapsed accepted-risk governance')
->assertSee('Open findings')
->assertDontSee('Current governance and findings signals look trustworthy.');
Livewire::test(BaselineCompareNow::class)
->assertSee('Action required')
->assertSee('Open findings')
->assertDontSee('Aligned');
});

View File

@ -248,3 +248,114 @@ function findingFilterIndicatorLabels($component): array
->not->toContain('Created from '.now()->subDays(2)->toFormattedDateString()) ->not->toContain('Created from '.now()->subDays(2)->toFormattedDateString())
->not->toContain('Created until '.now()->toFormattedDateString()); ->not->toContain('Created until '.now()->toFormattedDateString());
}); });
it('prefilters dashboard open-drift drill-throughs to the named findings subset', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$openDrift = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'status' => Finding::STATUS_TRIAGED,
]);
$openPermission = Finding::factory()->permissionPosture()->for($tenant)->create([
'status' => Finding::STATUS_TRIAGED,
'severity' => Finding::SEVERITY_HIGH,
]);
$resolvedDrift = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'status' => Finding::STATUS_RESOLVED,
]);
Livewire::withQueryParams([
'tab' => 'needs_action',
'finding_type' => Finding::FINDING_TYPE_DRIFT,
])
->test(ListFindings::class)
->assertSet('activeTab', 'needs_action')
->assertSet('tableFilters.finding_type.value', Finding::FINDING_TYPE_DRIFT)
->assertCanSeeTableRecords([$openDrift])
->assertCanNotSeeTableRecords([$openPermission, $resolvedDrift]);
});
it('prefilters dashboard high-severity drill-throughs without carrying unrelated finding filters', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$criticalDrift = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'status' => Finding::STATUS_REOPENED,
'severity' => Finding::SEVERITY_CRITICAL,
]);
$highPermission = Finding::factory()->permissionPosture()->for($tenant)->create([
'status' => Finding::STATUS_TRIAGED,
'severity' => Finding::SEVERITY_HIGH,
]);
$mediumDrift = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'status' => Finding::STATUS_NEW,
'severity' => Finding::SEVERITY_MEDIUM,
]);
Livewire::withQueryParams([
'tab' => 'needs_action',
'high_severity' => '1',
])
->test(ListFindings::class)
->assertSet('activeTab', 'needs_action')
->assertSet('tableFilters.high_severity.isActive', true)
->assertCanSeeTableRecords([$criticalDrift, $highPermission])
->assertCanNotSeeTableRecords([$mediumDrift]);
});
it('prefilters dashboard governance drill-throughs to the requested accepted-risk validity slice', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$healthyAccepted = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
\App\Models\FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $healthyAccepted->getKey(),
'requested_by_user_id' => (int) $requester->getKey(),
'owner_user_id' => (int) $requester->getKey(),
'approved_by_user_id' => (int) $approver->getKey(),
'status' => \App\Models\FindingException::STATUS_ACTIVE,
'current_validity_state' => \App\Models\FindingException::VALIDITY_VALID,
'request_reason' => 'Healthy governance',
'approval_reason' => 'Approved',
'requested_at' => now()->subDays(5),
'approved_at' => now()->subDays(4),
'effective_from' => now()->subDays(4),
'review_due_at' => now()->addDays(7),
'expires_at' => now()->addDays(14),
'evidence_summary' => ['reference_count' => 0],
]);
$lapsedAccepted = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
Livewire::withQueryParams([
'tab' => 'risk_accepted',
'governance_validity' => \App\Models\FindingException::VALIDITY_MISSING_SUPPORT,
])
->test(ListFindings::class)
->assertSet('activeTab', 'risk_accepted')
->assertSet('tableFilters.governance_validity.value', \App\Models\FindingException::VALIDITY_MISSING_SUPPORT)
->assertCanSeeTableRecords([$lapsedAccepted])
->assertCanNotSeeTableRecords([$healthyAccepted]);
});

View File

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\Operations;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('preserves tenant context and healthy activity semantics for dashboard operations drill-throughs', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$healthyActive = OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinute(),
'started_at' => now()->subMinute(),
]);
$staleActive = OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subHour(),
]);
$otherTenantActive = OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinute(),
'started_at' => now()->subMinute(),
]);
$this->actingAs($user);
Filament::setTenant($tenantA, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
Livewire::withQueryParams([
'tenant_id' => (string) $tenantA->getKey(),
'activeTab' => 'active',
])
->actingAs($user)
->test(Operations::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
->assertSet('activeTab', 'active')
->assertCanSeeTableRecords([$healthyActive])
->assertCanNotSeeTableRecords([$staleActive, $otherTenantActive]);
});
it('uses the blocked dashboard tab as the tenant-safe follow-up landing for failed, warning, and stalled runs', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$partialRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
]);
$failedRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
$blockedRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
]);
$staleRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subHour(),
]);
$healthyActive = OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinute(),
]);
$otherTenantFailed = OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
$this->actingAs($user);
Filament::setTenant($tenantA, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
Livewire::withQueryParams([
'tenant_id' => (string) $tenantA->getKey(),
'activeTab' => 'blocked',
])
->actingAs($user)
->test(Operations::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
->assertSet('activeTab', 'blocked')
->assertCanSeeTableRecords([$partialRun, $failedRun, $blockedRun, $staleRun])
->assertCanNotSeeTableRecords([$healthyActive, $otherTenantFailed]);
});
it('builds canonical dashboard operations URLs with tenant and tab continuity', function (): void {
$tenant = Tenant::factory()->create();
expect(OperationRunLinks::index($tenant, activeTab: 'active'))
->toBe(route('admin.operations.index', [
'tenant_id' => (int) $tenant->getKey(),
'activeTab' => 'active',
]))
->and(OperationRunLinks::index($tenant, activeTab: 'blocked'))
->toBe(route('admin.operations.index', [
'tenant_id' => (int) $tenant->getKey(),
'activeTab' => 'blocked',
]));
});

View File

@ -195,8 +195,8 @@
->assertCanSeeTableRecords([$runActiveA]) ->assertCanSeeTableRecords([$runActiveA])
->assertCanNotSeeTableRecords([$runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB]) ->assertCanNotSeeTableRecords([$runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
->set('activeTab', 'blocked') ->set('activeTab', 'blocked')
->assertCanSeeTableRecords([$runBlockedA]) ->assertCanSeeTableRecords([$runPartialA, $runBlockedA, $runFailedA])
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA, $runActiveB, $runFailedB]) ->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runActiveB, $runFailedB])
->set('activeTab', 'succeeded') ->set('activeTab', 'succeeded')
->assertCanSeeTableRecords([$runSucceededA]) ->assertCanSeeTableRecords([$runSucceededA])
->assertCanNotSeeTableRecords([$runActiveA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB]) ->assertCanNotSeeTableRecords([$runActiveA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
@ -213,7 +213,7 @@
]) ])
->get('/admin/operations') ->get('/admin/operations')
->assertOk() ->assertOk()
->assertSee('Blocked by prerequisite') ->assertSee('Needs follow-up')
->assertSee('Succeeded') ->assertSee('Succeeded')
->assertSee('Partial') ->assertSee('Partial')
->assertSee('Failed'); ->assertSee('Failed');

View File

@ -52,3 +52,18 @@
expect(OperationRunLinks::view($run, $tenant))->toBe($expectedUrl) expect(OperationRunLinks::view($run, $tenant))->toBe($expectedUrl)
->and(OperationRunLinks::view((int) $run->getKey(), $tenant))->toBe($expectedUrl); ->and(OperationRunLinks::view((int) $run->getKey(), $tenant))->toBe($expectedUrl);
})->group('ops-ux'); })->group('ops-ux');
it('preserves tenant prefilter and requested tab on canonical operations collection links', function (): void {
$tenant = Tenant::factory()->create();
expect(OperationRunLinks::index($tenant, activeTab: 'active'))
->toBe(route('admin.operations.index', [
'tenant_id' => (int) $tenant->getKey(),
'activeTab' => 'active',
]))
->and(OperationRunLinks::index($tenant, activeTab: 'blocked'))
->toBe(route('admin.operations.index', [
'tenant_id' => (int) $tenant->getKey(),
'activeTab' => 'blocked',
]));
})->group('ops-ux');