Compare commits
2 Commits
173-tenant
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 44898a98ac | |||
| 3a2a06e8d7 |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -123,6 +123,10 @@ ## 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, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages (174-evidence-freshness-publication-trust)
|
||||||
|
- PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned (174-evidence-freshness-publication-trust)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -142,8 +146,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 174-evidence-freshness-publication-trust: Added PHP 8.4, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages
|
||||||
|
- 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`
|
|
||||||
- 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 -->
|
||||||
|
|||||||
@ -7,9 +7,11 @@
|
|||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\TenantReviewStatus;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -90,14 +92,30 @@ public function mount(): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||||
|
$currentReviewTenantIds = TenantReview::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
|
||||||
|
->whereIn('status', [
|
||||||
|
TenantReviewStatus::Draft->value,
|
||||||
|
TenantReviewStatus::Ready->value,
|
||||||
|
TenantReviewStatus::Published->value,
|
||||||
|
])
|
||||||
|
->pluck('tenant_id')
|
||||||
|
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
|
||||||
|
->all();
|
||||||
|
|
||||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array {
|
||||||
$truth = $this->snapshotTruth($snapshot);
|
$truth = $this->snapshotTruth($snapshot);
|
||||||
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
||||||
|
$tenantId = (int) $snapshot->tenant_id;
|
||||||
|
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
|
||||||
|
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
|
||||||
|
? 'Create a current review from this evidence snapshot'
|
||||||
|
: $truth->nextStepText();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||||
'tenant_id' => (int) $snapshot->tenant_id,
|
'tenant_id' => $tenantId,
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
'completeness_state' => (string) $snapshot->completeness_state,
|
'completeness_state' => (string) $snapshot->completeness_state,
|
||||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||||
@ -114,7 +132,7 @@ public function mount(): void
|
|||||||
'color' => $freshnessSpec->color,
|
'color' => $freshnessSpec->color,
|
||||||
'icon' => $freshnessSpec->icon,
|
'icon' => $freshnessSpec->icon,
|
||||||
],
|
],
|
||||||
'next_step' => $truth->nextStepText(),
|
'next_step' => $nextStep,
|
||||||
'view_url' => $snapshot->tenant
|
'view_url' => $snapshot->tenant
|
||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,7 +171,12 @@ 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' => 'Operations are active',
|
||||||
|
'body' => "{$activeRuns} run(s) are active, but nothing currently needs follow-up.",
|
||||||
|
]
|
||||||
|
: [
|
||||||
'title' => 'No active operations',
|
'title' => 'No active operations',
|
||||||
'body' => 'Nothing is currently running for this tenant.',
|
'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 */
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -349,7 +350,9 @@ private function buildEvidenceSnapshotEnvelope(EvidenceSnapshot $snapshot): Arti
|
|||||||
'required' => 'Refresh evidence before using this snapshot',
|
'required' => 'Refresh evidence before using this snapshot',
|
||||||
'optional' => in_array($status, ['queued', 'generating'], true)
|
'optional' => in_array($status, ['queued', 'generating'], true)
|
||||||
? 'Wait for evidence generation to finish'
|
? 'Wait for evidence generation to finish'
|
||||||
: 'Review the evidence freshness before relying on this snapshot',
|
: ($freshnessState === 'stale'
|
||||||
|
? 'Refresh the stale evidence before relying on this snapshot'
|
||||||
|
: 'Review the evidence freshness before relying on this snapshot'),
|
||||||
default => null,
|
default => null,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -402,7 +405,7 @@ public function forTenantReviewFresh(TenantReview $review): ArtifactTruthEnvelop
|
|||||||
|
|
||||||
private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthEnvelope
|
private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
$review->loadMissing(['tenant', 'currentExportReviewPack', 'evidenceSnapshot']);
|
||||||
|
|
||||||
$summary = is_array($review->summary) ? $review->summary : [];
|
$summary = is_array($review->summary) ? $review->summary : [];
|
||||||
$publishBlockers = $review->publishBlockers();
|
$publishBlockers = $review->publishBlockers();
|
||||||
@ -410,6 +413,7 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
$completeness = $review->completenessEnum()->value;
|
$completeness = $review->completenessEnum()->value;
|
||||||
$sectionCounts = is_array($summary['section_state_counts'] ?? null) ? $summary['section_state_counts'] : [];
|
$sectionCounts = is_array($summary['section_state_counts'] ?? null) ? $summary['section_state_counts'] : [];
|
||||||
$staleSections = (int) ($sectionCounts['stale'] ?? 0);
|
$staleSections = (int) ($sectionCounts['stale'] ?? 0);
|
||||||
|
$sourceEvidence = $this->evidenceTrustBurden($review->evidenceSnapshot);
|
||||||
|
|
||||||
$artifactExistence = match ($status) {
|
$artifactExistence = match ($status) {
|
||||||
TenantReviewStatus::Archived, TenantReviewStatus::Superseded => 'historical_only',
|
TenantReviewStatus::Archived, TenantReviewStatus::Superseded => 'historical_only',
|
||||||
@ -417,24 +421,27 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
default => 'created',
|
default => 'created',
|
||||||
};
|
};
|
||||||
|
|
||||||
$contentState = match ($completeness) {
|
$contentState = match (true) {
|
||||||
TenantReviewCompletenessState::Complete->value => 'trusted',
|
$completeness === TenantReviewCompletenessState::Missing->value => 'missing_input',
|
||||||
TenantReviewCompletenessState::Partial->value => 'partial',
|
$completeness === TenantReviewCompletenessState::Partial->value || $sourceEvidence['isPartial'] => 'partial',
|
||||||
TenantReviewCompletenessState::Missing->value => 'missing_input',
|
$sourceEvidence['isMissing'] => 'missing_input',
|
||||||
TenantReviewCompletenessState::Stale->value => 'trusted',
|
$completeness === TenantReviewCompletenessState::Complete->value => 'trusted',
|
||||||
|
$completeness === TenantReviewCompletenessState::Stale->value => 'trusted',
|
||||||
default => 'partial',
|
default => 'partial',
|
||||||
};
|
};
|
||||||
|
|
||||||
$freshnessState = match (true) {
|
$freshnessState = match (true) {
|
||||||
$artifactExistence === 'historical_only' => 'stale',
|
$artifactExistence === 'historical_only' => 'stale',
|
||||||
$completeness === TenantReviewCompletenessState::Stale->value || $staleSections > 0 => 'stale',
|
$completeness === TenantReviewCompletenessState::Stale->value || $staleSections > 0 || $sourceEvidence['isStale'] => 'stale',
|
||||||
default => 'current',
|
default => 'current',
|
||||||
};
|
};
|
||||||
|
|
||||||
$publicationReadiness = match (true) {
|
$publicationReadiness = match (true) {
|
||||||
$artifactExistence === 'historical_only' => 'internal_only',
|
$artifactExistence === 'historical_only' => 'internal_only',
|
||||||
$status === TenantReviewStatus::Published => 'publishable',
|
|
||||||
$publishBlockers !== [] => 'blocked',
|
$publishBlockers !== [] => 'blocked',
|
||||||
|
$contentState === 'missing_input' => 'blocked',
|
||||||
|
$freshnessState === 'stale' || $contentState === 'partial' => 'internal_only',
|
||||||
|
$status === TenantReviewStatus::Published => 'publishable',
|
||||||
$status === TenantReviewStatus::Ready => 'publishable',
|
$status === TenantReviewStatus::Ready => 'publishable',
|
||||||
default => 'internal_only',
|
default => 'internal_only',
|
||||||
};
|
};
|
||||||
@ -442,16 +449,16 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
$actionability = match (true) {
|
$actionability = match (true) {
|
||||||
$artifactExistence === 'historical_only' => 'none',
|
$artifactExistence === 'historical_only' => 'none',
|
||||||
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
|
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
|
||||||
$publicationReadiness === 'internal_only' && $contentState === 'trusted' => 'optional',
|
$publicationReadiness === 'blocked' => 'required',
|
||||||
$freshnessState === 'stale' && $publishBlockers === [] => 'optional',
|
$publicationReadiness === 'internal_only' => 'optional',
|
||||||
default => 'required',
|
default => 'required',
|
||||||
};
|
};
|
||||||
|
|
||||||
$reasonCode = match (true) {
|
$reasonCode = match (true) {
|
||||||
$publishBlockers !== [] => 'review_publish_blocked',
|
$publishBlockers !== [] => 'review_publish_blocked',
|
||||||
$status === TenantReviewStatus::Failed => 'review_generation_failed',
|
$status === TenantReviewStatus::Failed => 'review_generation_failed',
|
||||||
$completeness === TenantReviewCompletenessState::Missing->value => 'review_missing_sections',
|
$contentState === 'missing_input' => 'review_missing_sections',
|
||||||
$completeness === TenantReviewCompletenessState::Stale->value => 'review_stale_sections',
|
$freshnessState === 'stale' => 'review_stale_sections',
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||||
@ -470,7 +477,11 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
$publicationReadiness === 'internal_only' => [
|
$publicationReadiness === 'internal_only' => [
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
'internal_only',
|
'internal_only',
|
||||||
'This review exists and is useful internally, but it is not yet ready for stakeholder publication.',
|
match (true) {
|
||||||
|
$freshnessState === 'stale' => 'This review is useful internally, but stale evidence should be refreshed before stakeholder publication.',
|
||||||
|
$contentState === 'partial' => 'This review is useful internally, but the evidence basis is partial and should be completed before stakeholder publication.',
|
||||||
|
default => 'This review exists and is useful internally, but it is not yet ready for stakeholder publication.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
$freshnessState === 'stale' => [
|
$freshnessState === 'stale' => [
|
||||||
BadgeDomain::GovernanceArtifactFreshness,
|
BadgeDomain::GovernanceArtifactFreshness,
|
||||||
@ -490,6 +501,8 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
|
|
||||||
if ($publishBlockers !== [] && $review->tenant !== null) {
|
if ($publishBlockers !== [] && $review->tenant !== null) {
|
||||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant);
|
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant);
|
||||||
|
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $review->tenant !== null && $review->evidenceSnapshot !== null) {
|
||||||
|
$nextActionUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $review->tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->makeEnvelope(
|
return $this->makeEnvelope(
|
||||||
@ -514,9 +527,11 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
nextActionLabel: $this->nextActionLabel(
|
nextActionLabel: $this->nextActionLabel(
|
||||||
$actionability,
|
$actionability,
|
||||||
$reason,
|
$reason,
|
||||||
match ($actionability) {
|
match (true) {
|
||||||
'required' => 'Resolve the review blockers before publication',
|
$publicationReadiness === 'blocked' => 'Resolve the review blockers before publication',
|
||||||
'optional' => 'Complete the remaining review work before publication',
|
$freshnessState === 'stale' => 'Refresh the evidence basis before publishing this review',
|
||||||
|
$contentState === 'partial' => 'Complete the evidence basis before publishing this review',
|
||||||
|
$actionability === 'optional' => 'Complete the remaining review work before publication',
|
||||||
default => null,
|
default => null,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -568,14 +583,18 @@ public function forReviewPackFresh(ReviewPack $pack): ArtifactTruthEnvelope
|
|||||||
|
|
||||||
private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelope
|
private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$pack->loadMissing(['tenant', 'tenantReview']);
|
$pack->loadMissing(['tenant', 'tenantReview', 'evidenceSnapshot']);
|
||||||
|
|
||||||
$summary = is_array($pack->summary) ? $pack->summary : [];
|
$summary = is_array($pack->summary) ? $pack->summary : [];
|
||||||
$status = (string) $pack->status;
|
$status = (string) $pack->status;
|
||||||
$evidenceResolution = is_array($summary['evidence_resolution'] ?? null) ? $summary['evidence_resolution'] : [];
|
$evidenceResolution = is_array($summary['evidence_resolution'] ?? null) ? $summary['evidence_resolution'] : [];
|
||||||
$sourceReview = $pack->tenantReview;
|
$sourceReview = $pack->tenantReview;
|
||||||
|
$sourceReviewTruth = $sourceReview instanceof TenantReview
|
||||||
|
? $this->forTenantReviewFresh($sourceReview)
|
||||||
|
: null;
|
||||||
$sourceBlockers = $sourceReview instanceof TenantReview ? $sourceReview->publishBlockers() : [];
|
$sourceBlockers = $sourceReview instanceof TenantReview ? $sourceReview->publishBlockers() : [];
|
||||||
$sourceReviewStatus = $sourceReview instanceof TenantReview ? $sourceReview->statusEnum() : null;
|
$sourceReviewStatus = $sourceReview instanceof TenantReview ? $sourceReview->statusEnum() : null;
|
||||||
|
$sourceEvidence = $this->evidenceTrustBurden($pack->evidenceSnapshot);
|
||||||
|
|
||||||
$artifactExistence = match ($status) {
|
$artifactExistence = match ($status) {
|
||||||
ReviewPackStatus::Queued->value, ReviewPackStatus::Generating->value => 'not_created',
|
ReviewPackStatus::Queued->value, ReviewPackStatus::Generating->value => 'not_created',
|
||||||
@ -588,23 +607,31 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
$artifactExistence === 'not_created' => 'missing_input',
|
$artifactExistence === 'not_created' => 'missing_input',
|
||||||
$status === ReviewPackStatus::Failed->value => 'missing_input',
|
$status === ReviewPackStatus::Failed->value => 'missing_input',
|
||||||
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'missing_input',
|
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'missing_input',
|
||||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'partial',
|
$sourceReviewTruth?->contentState === 'missing_input' => 'missing_input',
|
||||||
|
$sourceReviewTruth?->contentState === 'partial' || $sourceEvidence['isPartial'] => 'partial',
|
||||||
default => 'trusted',
|
default => 'trusted',
|
||||||
};
|
};
|
||||||
|
|
||||||
$freshnessState = $artifactExistence === 'historical_only' ? 'stale' : 'current';
|
$freshnessState = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => 'stale',
|
||||||
|
$sourceReviewTruth?->freshnessState === 'stale' || $sourceEvidence['isStale'] => 'stale',
|
||||||
|
default => 'current',
|
||||||
|
};
|
||||||
|
|
||||||
$publicationReadiness = match (true) {
|
$publicationReadiness = match (true) {
|
||||||
$artifactExistence === 'historical_only' => 'internal_only',
|
$artifactExistence === 'historical_only' => 'internal_only',
|
||||||
$artifactExistence === 'not_created' => 'blocked',
|
$artifactExistence === 'not_created' => 'blocked',
|
||||||
$status === ReviewPackStatus::Failed->value => 'blocked',
|
$status === ReviewPackStatus::Failed->value => 'blocked',
|
||||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'blocked',
|
$sourceReviewTruth?->publicationReadiness === 'blocked' => 'blocked',
|
||||||
|
$sourceReviewTruth?->publicationReadiness === 'internal_only' => 'internal_only',
|
||||||
|
$freshnessState === 'stale' || $contentState === 'partial' => 'internal_only',
|
||||||
$sourceReviewStatus === TenantReviewStatus::Draft || $sourceReviewStatus === TenantReviewStatus::Failed => 'internal_only',
|
$sourceReviewStatus === TenantReviewStatus::Draft || $sourceReviewStatus === TenantReviewStatus::Failed => 'internal_only',
|
||||||
default => $status === ReviewPackStatus::Ready->value ? 'publishable' : 'blocked',
|
default => $status === ReviewPackStatus::Ready->value ? 'publishable' : 'blocked',
|
||||||
};
|
};
|
||||||
|
|
||||||
$actionability = match (true) {
|
$actionability = match (true) {
|
||||||
$artifactExistence === 'historical_only' => 'none',
|
$artifactExistence === 'historical_only' => 'none',
|
||||||
$publicationReadiness === 'publishable' => 'none',
|
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
|
||||||
$publicationReadiness === 'internal_only' => 'optional',
|
$publicationReadiness === 'internal_only' => 'optional',
|
||||||
default => 'required',
|
default => 'required',
|
||||||
};
|
};
|
||||||
@ -612,7 +639,7 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
$reasonCode = match (true) {
|
$reasonCode = match (true) {
|
||||||
$status === ReviewPackStatus::Failed->value => 'review_pack_generation_failed',
|
$status === ReviewPackStatus::Failed->value => 'review_pack_generation_failed',
|
||||||
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'review_pack_missing_snapshot',
|
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'review_pack_missing_snapshot',
|
||||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'review_pack_source_not_publishable',
|
$sourceReviewTruth?->publicationReadiness === 'blocked' || $sourceReviewTruth?->publicationReadiness === 'internal_only' => 'review_pack_source_not_publishable',
|
||||||
$artifactExistence === 'historical_only' => 'review_pack_expired',
|
$artifactExistence === 'historical_only' => 'review_pack_expired',
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
@ -632,7 +659,11 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
$publicationReadiness === 'internal_only' => [
|
$publicationReadiness === 'internal_only' => [
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
'internal_only',
|
'internal_only',
|
||||||
'This pack can be reviewed internally, but the source review is not currently publishable.',
|
match (true) {
|
||||||
|
$freshnessState === 'stale' => 'This pack is downloadable, but the source review relies on stale evidence and should stay internal until refreshed.',
|
||||||
|
$contentState === 'partial' => 'This pack is downloadable, but the source review relies on partial evidence and should stay internal until the evidence basis is completed.',
|
||||||
|
default => 'This pack can be reviewed internally, but the source review is not currently publishable.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
default => [
|
default => [
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
@ -645,6 +676,8 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
|
|
||||||
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
|
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
|
||||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant);
|
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant);
|
||||||
|
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $pack->tenant !== null && $pack->evidenceSnapshot !== null) {
|
||||||
|
$nextActionUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $pack->evidenceSnapshot], tenant: $pack->tenant);
|
||||||
} elseif ($pack->operation_run_id !== null) {
|
} elseif ($pack->operation_run_id !== null) {
|
||||||
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
|
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
|
||||||
}
|
}
|
||||||
@ -671,9 +704,11 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
nextActionLabel: $this->nextActionLabel(
|
nextActionLabel: $this->nextActionLabel(
|
||||||
$actionability,
|
$actionability,
|
||||||
$reason,
|
$reason,
|
||||||
match ($actionability) {
|
match (true) {
|
||||||
'required' => 'Open the source review before sharing this pack',
|
$publicationReadiness === 'blocked' => 'Open the source review before sharing this pack',
|
||||||
'optional' => 'Review the source review before sharing this pack',
|
$freshnessState === 'stale' => 'Refresh the source review before sharing this pack',
|
||||||
|
$contentState === 'partial' => 'Complete the source review before sharing this pack',
|
||||||
|
$actionability === 'optional' => 'Review the source review before sharing this pack',
|
||||||
default => null,
|
default => null,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -704,6 +739,30 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{isMissing: bool, isPartial: bool, isStale: bool}
|
||||||
|
*/
|
||||||
|
private function evidenceTrustBurden(?EvidenceSnapshot $snapshot): array
|
||||||
|
{
|
||||||
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||||
|
return [
|
||||||
|
'isMissing' => false,
|
||||||
|
'isPartial' => false,
|
||||||
|
'isStale' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
||||||
|
$completeness = $snapshot->completenessState();
|
||||||
|
$staleDimensions = (int) ($summary['stale_dimensions'] ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'isMissing' => $completeness === EvidenceCompletenessState::Missing,
|
||||||
|
'isPartial' => $completeness === EvidenceCompletenessState::Partial,
|
||||||
|
'isStale' => $completeness === EvidenceCompletenessState::Stale || $staleDimensions > 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
return $this->resolveEnvelope(
|
return $this->resolveEnvelope(
|
||||||
@ -808,7 +867,7 @@ private function buildOperationRunEnvelope(OperationRun $run): ArtifactTruthEnve
|
|||||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||||
nextActionLabel: $reason?->firstNextStep()?->label
|
nextActionLabel: $reason?->firstNextStep()?->label
|
||||||
?? ($actionability === 'required'
|
?? ($actionability === 'required'
|
||||||
? 'Inspect the blocked operation details before retrying'
|
? 'Inspect the blocked run details before retrying'
|
||||||
: 'Wait for the artifact-producing operation to finish'),
|
: 'Wait for the artifact-producing operation to finish'),
|
||||||
nextActionUrl: null,
|
nextActionUrl: null,
|
||||||
relatedRunId: (int) $run->getKey(),
|
relatedRunId: (int) $run->getKey(),
|
||||||
|
|||||||
@ -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'"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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.
|
||||||
@ -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'
|
||||||
267
specs/173-tenant-dashboard-truth-alignment/data-model.md
Normal file
267
specs/173-tenant-dashboard-truth-alignment/data-model.md
Normal 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.
|
||||||
278
specs/173-tenant-dashboard-truth-alignment/plan.md
Normal file
278
specs/173-tenant-dashboard-truth-alignment/plan.md
Normal 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.
|
||||||
128
specs/173-tenant-dashboard-truth-alignment/quickstart.md
Normal file
128
specs/173-tenant-dashboard-truth-alignment/quickstart.md
Normal 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.
|
||||||
73
specs/173-tenant-dashboard-truth-alignment/research.md
Normal file
73
specs/173-tenant-dashboard-truth-alignment/research.md
Normal 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.
|
||||||
231
specs/173-tenant-dashboard-truth-alignment/spec.md
Normal file
231
specs/173-tenant-dashboard-truth-alignment/spec.md
Normal 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.
|
||||||
215
specs/173-tenant-dashboard-truth-alignment/tasks.md
Normal file
215
specs/173-tenant-dashboard-truth-alignment/tasks.md
Normal 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.
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-04
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Required repo contract references such as routes, capabilities, and affected operator surfaces are included because this repository's spec format requires them; the requirements and outcomes themselves remain user-facing and do not prescribe code structure.
|
||||||
|
- No clarification markers remain. The feature is ready for `/speckit.plan`.
|
||||||
@ -0,0 +1,396 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Evidence Review Trust Surfaces Contract
|
||||||
|
version: 1.0.0
|
||||||
|
description: >-
|
||||||
|
Internal reference contract for the rendered HTML surfaces affected by Spec 174.
|
||||||
|
These routes continue to return HTML through Filament and Livewire. The vendor
|
||||||
|
media types below document the structured truth payloads that must be derivable
|
||||||
|
before rendering. This is not a public API commitment.
|
||||||
|
paths:
|
||||||
|
/admin/evidence/overview:
|
||||||
|
get:
|
||||||
|
summary: Canonical evidence overview
|
||||||
|
description: >-
|
||||||
|
Returns the rendered evidence overview for entitled tenants in the current workspace.
|
||||||
|
The vendor media type documents the derived row contract used to communicate
|
||||||
|
artifact truth, freshness, and next steps.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered evidence overview page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.evidence-overview+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EvidenceOverviewPage'
|
||||||
|
'404':
|
||||||
|
description: Workspace context is missing or the viewer is not entitled to the relevant scope
|
||||||
|
/admin/reviews:
|
||||||
|
get:
|
||||||
|
summary: Canonical review register
|
||||||
|
description: >-
|
||||||
|
Returns the rendered review register for entitled tenants in the current workspace.
|
||||||
|
The vendor media type documents the row-level trust and publication contract.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered review register page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.review-register+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ReviewRegisterPage'
|
||||||
|
'404':
|
||||||
|
description: Workspace context is missing or the viewer is not entitled to the relevant scope
|
||||||
|
/admin/t/{tenant}/evidence/{snapshot}:
|
||||||
|
get:
|
||||||
|
summary: Tenant-scoped evidence snapshot detail
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: snapshot
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered evidence snapshot detail page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.evidence-snapshot-detail+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EvidenceSnapshotDetailPage'
|
||||||
|
'403':
|
||||||
|
description: Viewer is in tenant scope but lacks the required manage capability for actions
|
||||||
|
'404':
|
||||||
|
description: Snapshot is not visible because it does not exist or tenant entitlement is missing
|
||||||
|
/admin/t/{tenant}/reviews/{review}:
|
||||||
|
get:
|
||||||
|
summary: Tenant-scoped review detail
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: review
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered tenant review detail page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.tenant-review-detail+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantReviewDetailPage'
|
||||||
|
'403':
|
||||||
|
description: Viewer is in tenant scope but lacks the required manage capability for actions
|
||||||
|
'404':
|
||||||
|
description: Review is not visible because it does not exist or tenant entitlement is missing
|
||||||
|
/admin/t/{tenant}/review-packs/{pack}:
|
||||||
|
get:
|
||||||
|
summary: Tenant-scoped review pack detail
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: pack
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered review pack detail page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.review-pack-detail+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ReviewPackDetailPage'
|
||||||
|
'403':
|
||||||
|
description: Viewer is in tenant scope but lacks the required manage capability for actions
|
||||||
|
'404':
|
||||||
|
description: Review pack is not visible because it does not exist or tenant entitlement is missing
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
ArtifactTruthSummary:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- primaryLabel
|
||||||
|
- contentState
|
||||||
|
- freshnessState
|
||||||
|
- actionability
|
||||||
|
properties:
|
||||||
|
primaryLabel:
|
||||||
|
type: string
|
||||||
|
primaryExplanation:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
contentState:
|
||||||
|
type: string
|
||||||
|
freshnessState:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- current
|
||||||
|
- stale
|
||||||
|
- unknown
|
||||||
|
publicationReadiness:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
enum:
|
||||||
|
- publishable
|
||||||
|
- internal_only
|
||||||
|
- blocked
|
||||||
|
actionability:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- none
|
||||||
|
- optional
|
||||||
|
- required
|
||||||
|
nextActionLabel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
nextActionUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
diagnosticLabel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
Badge:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
color:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
icon:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
EvidenceOverviewRow:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tenantName
|
||||||
|
- tenantId
|
||||||
|
- snapshotId
|
||||||
|
- completenessState
|
||||||
|
- artifactTruth
|
||||||
|
- freshness
|
||||||
|
- nextStep
|
||||||
|
properties:
|
||||||
|
tenantName:
|
||||||
|
type: string
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
snapshotId:
|
||||||
|
type: integer
|
||||||
|
completenessState:
|
||||||
|
type: string
|
||||||
|
generatedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
missingDimensions:
|
||||||
|
type: integer
|
||||||
|
staleDimensions:
|
||||||
|
type: integer
|
||||||
|
artifactTruth:
|
||||||
|
$ref: '#/components/schemas/ArtifactTruthSummary'
|
||||||
|
freshness:
|
||||||
|
$ref: '#/components/schemas/Badge'
|
||||||
|
nextStep:
|
||||||
|
type: string
|
||||||
|
viewUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
ReviewRegisterRow:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tenantName
|
||||||
|
- tenantId
|
||||||
|
- reviewId
|
||||||
|
- status
|
||||||
|
- completenessState
|
||||||
|
- artifactTruth
|
||||||
|
- publication
|
||||||
|
- nextStep
|
||||||
|
properties:
|
||||||
|
tenantName:
|
||||||
|
type: string
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
reviewId:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
completenessState:
|
||||||
|
type: string
|
||||||
|
generatedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
publishedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
artifactTruth:
|
||||||
|
$ref: '#/components/schemas/ArtifactTruthSummary'
|
||||||
|
publication:
|
||||||
|
$ref: '#/components/schemas/Badge'
|
||||||
|
nextStep:
|
||||||
|
type: string
|
||||||
|
viewUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
EvidenceOverviewPage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- rows
|
||||||
|
properties:
|
||||||
|
rows:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EvidenceOverviewRow'
|
||||||
|
ReviewRegisterPage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- rows
|
||||||
|
properties:
|
||||||
|
rows:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ReviewRegisterRow'
|
||||||
|
EvidenceSnapshotDetailPage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- recordId
|
||||||
|
- tenantId
|
||||||
|
- completenessState
|
||||||
|
- artifactTruth
|
||||||
|
properties:
|
||||||
|
recordId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
completenessState:
|
||||||
|
type: string
|
||||||
|
generatedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
artifactTruth:
|
||||||
|
$ref: '#/components/schemas/ArtifactTruthSummary'
|
||||||
|
linkedReviewUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
linkedRunUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
TenantReviewDetailPage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- recordId
|
||||||
|
- tenantId
|
||||||
|
- status
|
||||||
|
- completenessState
|
||||||
|
- artifactTruth
|
||||||
|
properties:
|
||||||
|
recordId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
completenessState:
|
||||||
|
type: string
|
||||||
|
generatedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
publishedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
artifactTruth:
|
||||||
|
$ref: '#/components/schemas/ArtifactTruthSummary'
|
||||||
|
linkedEvidenceUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
linkedPackUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
ReviewPackDetailPage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- recordId
|
||||||
|
- tenantId
|
||||||
|
- status
|
||||||
|
- artifactTruth
|
||||||
|
properties:
|
||||||
|
recordId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
generatedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
expiresAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
artifactTruth:
|
||||||
|
$ref: '#/components/schemas/ArtifactTruthSummary'
|
||||||
|
linkedReviewUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
linkedEvidenceUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
268
specs/174-evidence-freshness-publication-trust/data-model.md
Normal file
268
specs/174-evidence-freshness-publication-trust/data-model.md
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
# Data Model: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not add or modify persisted domain entities. It strengthens the derived trust-propagation model that transforms existing evidence, review, and pack records into operator-facing truth across tenant-scoped detail pages and canonical summary pages.
|
||||||
|
|
||||||
|
The key design constraint is that freshness and publication trust remain derived from existing fields and relationships:
|
||||||
|
|
||||||
|
- evidence-source freshness signals
|
||||||
|
- evidence snapshot completeness and summary state
|
||||||
|
- tenant review completeness and publish blockers
|
||||||
|
- review pack linkage back to a source review and source evidence snapshot
|
||||||
|
- the existing `ArtifactTruthEnvelope` dimensions
|
||||||
|
|
||||||
|
## Existing Persistent Entities
|
||||||
|
|
||||||
|
### 1. EvidenceSnapshot
|
||||||
|
|
||||||
|
- Purpose: Immutable tenant-scoped evidence basis assembled from multiple evidence dimensions.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `status`
|
||||||
|
- `completeness_state`
|
||||||
|
- `summary`
|
||||||
|
- `generated_at`
|
||||||
|
- `expires_at`
|
||||||
|
- `operation_run_id`
|
||||||
|
- `fingerprint`
|
||||||
|
- Key summary fields used by this feature:
|
||||||
|
- `dimension_count`
|
||||||
|
- `missing_dimensions`
|
||||||
|
- `stale_dimensions`
|
||||||
|
- `dimensions[]`
|
||||||
|
- `finding_count`
|
||||||
|
- `report_count`
|
||||||
|
- `operation_count`
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- `items`
|
||||||
|
- `tenantReviews`
|
||||||
|
- `reviewPacks`
|
||||||
|
- `operationRun`
|
||||||
|
- `tenant`
|
||||||
|
|
||||||
|
### 2. EvidenceSnapshotItem
|
||||||
|
|
||||||
|
- Purpose: Dimension-level evidence record inside one snapshot.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `evidence_snapshot_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `dimension_key`
|
||||||
|
- `state`
|
||||||
|
- `required`
|
||||||
|
- `measured_at`
|
||||||
|
- `freshness_at`
|
||||||
|
- `source_kind`
|
||||||
|
- `source_record_type`
|
||||||
|
- `source_record_id`
|
||||||
|
- `summary_payload`
|
||||||
|
- `sort_order`
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- `snapshot`
|
||||||
|
- `tenant`
|
||||||
|
|
||||||
|
### 3. TenantReview
|
||||||
|
|
||||||
|
- Purpose: Tenant-scoped review artifact anchored to one evidence snapshot.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `evidence_snapshot_id`
|
||||||
|
- `status`
|
||||||
|
- `completeness_state`
|
||||||
|
- `summary`
|
||||||
|
- `generated_at`
|
||||||
|
- `published_at`
|
||||||
|
- `archived_at`
|
||||||
|
- `current_export_review_pack_id`
|
||||||
|
- `operation_run_id`
|
||||||
|
- `fingerprint`
|
||||||
|
- Key summary fields used by this feature:
|
||||||
|
- `publish_blockers[]`
|
||||||
|
- `section_state_counts.complete`
|
||||||
|
- `section_state_counts.partial`
|
||||||
|
- `section_state_counts.missing`
|
||||||
|
- `section_state_counts.stale`
|
||||||
|
- `section_count`
|
||||||
|
- `finding_count`
|
||||||
|
- `report_count`
|
||||||
|
- `operation_count`
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- `evidenceSnapshot`
|
||||||
|
- `sections`
|
||||||
|
- `currentExportReviewPack`
|
||||||
|
- `reviewPacks`
|
||||||
|
- `operationRun`
|
||||||
|
- `tenant`
|
||||||
|
|
||||||
|
### 4. ReviewPack
|
||||||
|
|
||||||
|
- Purpose: Tenant-scoped export artifact derived from a review and evidence basis.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `tenant_review_id`
|
||||||
|
- `evidence_snapshot_id`
|
||||||
|
- `status`
|
||||||
|
- `summary`
|
||||||
|
- `generated_at`
|
||||||
|
- `expires_at`
|
||||||
|
- `file_disk`
|
||||||
|
- `file_path`
|
||||||
|
- `file_size`
|
||||||
|
- `operation_run_id`
|
||||||
|
- `fingerprint`
|
||||||
|
- Key summary fields used by this feature:
|
||||||
|
- `review_status`
|
||||||
|
- `review_completeness_state`
|
||||||
|
- `evidence_resolution.outcome`
|
||||||
|
- `evidence_resolution.snapshot_id`
|
||||||
|
- `evidence_resolution.snapshot_fingerprint`
|
||||||
|
- `evidence_resolution.completeness_state`
|
||||||
|
- `finding_count`
|
||||||
|
- `report_count`
|
||||||
|
- `operation_count`
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- `tenantReview`
|
||||||
|
- `evidenceSnapshot`
|
||||||
|
- `operationRun`
|
||||||
|
- `tenant`
|
||||||
|
|
||||||
|
## Existing Derived State Chain
|
||||||
|
|
||||||
|
### A. Evidence Freshness Derivation
|
||||||
|
|
||||||
|
Evidence freshness is already determined in the evidence domain before any UI surface is rendered.
|
||||||
|
|
||||||
|
1. Each evidence source provider returns a dimension payload with:
|
||||||
|
- `state`
|
||||||
|
- `required`
|
||||||
|
- `measured_at`
|
||||||
|
- `freshness_at`
|
||||||
|
2. `EvidenceCompletenessEvaluator` rolls required dimension states into snapshot completeness:
|
||||||
|
- `missing` wins first
|
||||||
|
- then `stale`
|
||||||
|
- then `partial`
|
||||||
|
- otherwise `complete`
|
||||||
|
3. `EvidenceSnapshotService` persists:
|
||||||
|
- `completeness_state`
|
||||||
|
- `summary.missing_dimensions`
|
||||||
|
- `summary.stale_dimensions`
|
||||||
|
- per-dimension summary state
|
||||||
|
|
||||||
|
This feature must reuse that chain instead of replacing it.
|
||||||
|
|
||||||
|
### B. Review Readiness Derivation
|
||||||
|
|
||||||
|
Tenant review readiness is already derived from section completeness and blockers.
|
||||||
|
|
||||||
|
1. `TenantReviewSectionFactory` and related composition logic produce section completeness states.
|
||||||
|
2. `TenantReviewReadinessGate` derives:
|
||||||
|
- `blockersForSections()`
|
||||||
|
- `completenessForSections()`
|
||||||
|
- `statusForSections()`
|
||||||
|
3. `TenantReview.summary` persists:
|
||||||
|
- `publish_blockers[]`
|
||||||
|
- `section_state_counts.*`
|
||||||
|
|
||||||
|
This feature must reuse those publish blockers and section-state counts rather than adding a parallel publish-readiness source.
|
||||||
|
|
||||||
|
## Derived View Models
|
||||||
|
|
||||||
|
### 1. ArtifactTruthPropagationModel
|
||||||
|
|
||||||
|
This is the conceptual chain, not a new class.
|
||||||
|
|
||||||
|
| Stage | Inputs | Derived outputs |
|
||||||
|
|---|---|---|
|
||||||
|
| Evidence source item | source-specific timestamps and source-record state | per-dimension `state`, `freshness_at`, `required` |
|
||||||
|
| Evidence snapshot | item states, snapshot status, summary counts | `artifactExistence`, `contentState`, `freshnessState`, `actionability` |
|
||||||
|
| Tenant review | review status, review completeness, section-state counts, publish blockers, linked snapshot | `artifactExistence`, `contentState`, `freshnessState`, `publicationReadiness`, `actionability` |
|
||||||
|
| Review pack | pack status, evidence resolution, linked review trust burden, linked snapshot burden | `artifactExistence`, `contentState`, `freshnessState`, `publicationReadiness`, `actionability` |
|
||||||
|
| Canonical summary row | tenant label, record timestamps, derived artifact truth envelope | row-level artifact truth, publication signal, freshness signal, next step |
|
||||||
|
|
||||||
|
### 2. SnapshotTruthModel
|
||||||
|
|
||||||
|
Derived from `EvidenceSnapshot` plus `ArtifactTruthPresenter::buildEvidenceSnapshotEnvelope()`.
|
||||||
|
|
||||||
|
| Field | Meaning | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `artifactExistence` | whether a current or historical snapshot exists | snapshot `status` |
|
||||||
|
| `contentState` | whether the evidence basis is trusted, partial, missing, or empty | snapshot `completeness_state`, `summary.dimension_count` |
|
||||||
|
| `freshnessState` | whether the evidence basis is current or stale | snapshot `completeness_state`, `summary.stale_dimensions`, historical status |
|
||||||
|
| `actionability` | whether the operator must act, may optionally act, or can do nothing | derived from freshness and completeness |
|
||||||
|
| `nextStepText` | operator-facing refresh or wait guidance | existing presenter logic |
|
||||||
|
|
||||||
|
### 3. ReviewTruthModel
|
||||||
|
|
||||||
|
Derived from `TenantReview` plus `ArtifactTruthPresenter::buildTenantReviewEnvelope()`.
|
||||||
|
|
||||||
|
| Field | Meaning | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `artifactExistence` | whether the review is current, failed, or historical | review status |
|
||||||
|
| `contentState` | whether the review body is complete enough or partial | review completeness |
|
||||||
|
| `freshnessState` | whether the review evidence basis is stale | review completeness and `summary.section_state_counts.stale` |
|
||||||
|
| `publicationReadiness` | whether the review is blocked, internal-only, or publishable | review status, publish blockers, and this feature's stricter freshness/partiality propagation |
|
||||||
|
| `actionability` | whether the operator must resolve blockers, should refresh, or can leave the review alone | derived from publication and freshness burden |
|
||||||
|
| `nextStepText` | resolve blockers, complete work, refresh evidence, or no action needed | existing presenter logic tightened by this feature |
|
||||||
|
|
||||||
|
### 4. PackTruthModel
|
||||||
|
|
||||||
|
Derived from `ReviewPack` plus `ArtifactTruthPresenter::buildReviewPackEnvelope()`.
|
||||||
|
|
||||||
|
| Field | Meaning | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `artifactExistence` | whether a current, failed, or historical pack exists | pack status |
|
||||||
|
| `contentState` | whether the pack has a trustworthy source basis | evidence resolution and source review burden |
|
||||||
|
| `freshnessState` | whether the pack should be treated as current or stale for operator trust | pack expiration plus propagated source review/evidence staleness |
|
||||||
|
| `publicationReadiness` | whether the pack is blocked, internal-only, or publishable | pack status plus propagated review and evidence burden |
|
||||||
|
| `actionability` | whether the operator must revisit the review or simply note caution | derived from the trust posture |
|
||||||
|
| `nextStepText` | open source review, refresh evidence, or no action needed | existing presenter logic tightened by this feature |
|
||||||
|
|
||||||
|
### 5. CanonicalSummaryRowModel
|
||||||
|
|
||||||
|
Used conceptually by both `EvidenceOverview` and `ReviewRegister`.
|
||||||
|
|
||||||
|
| Field | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `tenantName` | string | Maintain tenant context in canonical pages |
|
||||||
|
| `recordId` | int | Link the summary row back to the tenant-scoped detail page |
|
||||||
|
| `artifactTruth` | label + badge spec + explanation | Primary trust summary shown in the row |
|
||||||
|
| `freshness` | badge spec | Evidence overview freshness summary |
|
||||||
|
| `publication` | badge spec | Review register publication summary |
|
||||||
|
| `nextStep` | string | Operator-facing follow-up guidance that matches detail surfaces |
|
||||||
|
| `viewUrl` | string | Drill-through to the tenant-scoped detail page |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Canonical rows must never appear calmer than the corresponding detail pages.
|
||||||
|
- `nextStep` may be shorter than detail guidance, but not contradictory.
|
||||||
|
- Canonical rows must stay derived from the same truth envelope rather than page-local heuristics.
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
- A snapshot can be structurally complete and still stale; the derived truth must preserve that distinction.
|
||||||
|
- A review can be internally useful while not being publishable; the derived truth must preserve that distinction.
|
||||||
|
- A pack must not be calmer than its source review or source evidence basis.
|
||||||
|
- Canonical overview and register rows must not contradict the tenant-scoped detail view for the same artifact.
|
||||||
|
- Freshness, completeness, and publication readiness remain separate dimensions even when they jointly influence the primary label.
|
||||||
|
|
||||||
|
## State Notes
|
||||||
|
|
||||||
|
This feature introduces no new persisted state.
|
||||||
|
|
||||||
|
Existing state and badge families that remain canonical:
|
||||||
|
|
||||||
|
- `EvidenceCompletenessState`
|
||||||
|
- `EvidenceSnapshotStatus`
|
||||||
|
- `TenantReviewCompletenessState`
|
||||||
|
- `TenantReviewStatus`
|
||||||
|
- `ReviewPackStatus`
|
||||||
|
- `BadgeDomain::GovernanceArtifactFreshness`
|
||||||
|
- `BadgeDomain::GovernanceArtifactPublicationReadiness`
|
||||||
|
|
||||||
|
The feature only changes how these existing states combine into operator-facing trust across multiple related surfaces.
|
||||||
294
specs/174-evidence-freshness-publication-trust/plan.md
Normal file
294
specs/174-evidence-freshness-publication-trust/plan.md
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
# Implementation Plan: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
**Branch**: `174-evidence-freshness-publication-trust` | **Date**: 2026-04-04 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/174-evidence-freshness-publication-trust/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/174-evidence-freshness-publication-trust/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Harden evidence freshness and publication trust across the existing evidence snapshot, tenant review, review pack, evidence overview, and canonical review register surfaces without adding new persistence, a new trust layer, or a new reporting subsystem. The implementation will reuse the source-derived stale semantics and their existing source-defined freshness thresholds, tighten propagation in `ArtifactTruthPresenter`, keep review readiness and publication readiness distinct, preserve the current tenant and canonical routes and action inventory, and close the existing cross-surface gap where stale or partial evidence can still look publishable.
|
||||||
|
|
||||||
|
Key approach: keep the work inside the current `EvidenceSnapshotService` freshness semantics, `TenantReviewReadinessGate`, `ArtifactTruthPresenter`, tenant-scoped Filament resources, and canonical summary pages. The slice is primarily about better derivation and consistent display, not about new models or new workflows.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4, Blade
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages
|
||||||
|
**Storage**: PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned
|
||||||
|
**Testing**: Pest feature tests and Livewire page tests run via Sail, plus existing governance-artifact fixture helpers
|
||||||
|
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
|
||||||
|
**Project Type**: Laravel monolith web application
|
||||||
|
**Performance Goals**: Preserve DB-only render behavior on detail and canonical surfaces, avoid any render-time external calls, keep list-row truth derivation lightweight enough for canonical table scans, and keep operator trust signals readable within a 5-10 second scan on summary surfaces
|
||||||
|
**Constraints**: No new tables, no new enum families, no new presenter or resolver subsystem, no route changes, no RBAC drift, no destructive-action placement drift, no global asset changes, and no new global freshness engine if existing source-derived stale semantics are sufficient
|
||||||
|
**Scale/Scope**: Five operator-facing surfaces (`/admin/evidence/overview`, `/admin/reviews`, `/admin/t/{tenant}/evidence/{snapshot}`, `/admin/t/{tenant}/reviews/{review}`, `/admin/t/{tenant}/review-packs/{pack}`), one central truth presenter, existing readiness helpers, and focused regression coverage across fresh, stale, partial, blocked, internal-only, and publishable scenarios
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Inventory-first | Pass | Evidence freshness remains derived from the existing snapshot-item and source-evaluation chain; no new snapshot ownership semantics |
|
||||||
|
| Read/write separation | Pass | This slice primarily changes truth derivation and display; existing mutate actions remain unchanged |
|
||||||
|
| Graph contract path | Pass | No new Graph calls or contract-registry changes are introduced |
|
||||||
|
| Deterministic capabilities | Pass | No new capability derivation or role mapping is added |
|
||||||
|
| RBAC-UX planes and 404 vs 403 | Pass | Tenant-scoped resources remain tenant-scoped; canonical `/admin` pages stay workspace- and tenant-entitlement-safe |
|
||||||
|
| Workspace isolation | Pass | Canonical summary pages continue to derive rows only from entitled tenants in the current workspace |
|
||||||
|
| Tenant isolation | Pass | Drill-through links remain tenant-scoped and non-entitled users remain deny-as-not-found |
|
||||||
|
| Destructive confirmation | Pass | Existing destructive actions (`Expire snapshot`, `Archive review`, `Expire pack`) already require confirmation and remain unchanged |
|
||||||
|
| Global search safety | Pass | No global-search behavior is added or broadened; `EvidenceSnapshotResource`, `TenantReviewResource`, and `ReviewPackResource` already have view pages |
|
||||||
|
| Run observability | Pass | Existing evidence, review, and pack generation flows keep their current `OperationRun` types and ownership; no new run type is introduced |
|
||||||
|
| Ops-UX 3-surface feedback | Pass | No toast, progress, or terminal-notification behavior changes |
|
||||||
|
| Ops-UX lifecycle ownership | Pass | No `OperationRun.status` or `outcome` transition logic is touched |
|
||||||
|
| Ops-UX summary counts | Pass | No changes to `summary_counts` contracts are required |
|
||||||
|
| Ops-UX guards | Pass | Existing operation lifecycle guards stay in place; this feature adds truth-surface regression tests instead |
|
||||||
|
| Data minimization | Pass | No new payload exposure or raw-report surface is introduced |
|
||||||
|
| Proportionality (PROP-001) | Pass | The implementation stays inside existing freshness, readiness, and truth layers rather than adding persistence or abstraction |
|
||||||
|
| No premature abstraction (ABSTR-001) | Pass | No new resolver, gate, presenter family, registry, or taxonomy is planned |
|
||||||
|
| Persisted truth (PERSIST-001) | Pass | All new semantics remain derived from existing timestamps, summary state, and linked records |
|
||||||
|
| Behavioral state (STATE-001) | Pass | No new persisted states are introduced; existing stale/partial/publishable/internal-only semantics become stricter |
|
||||||
|
| UI semantics (UI-SEM-001) | Pass | Existing badge and truth primitives are reused; no second interpretation framework is introduced |
|
||||||
|
| V1 explicitness / few layers (V1-EXP-001, LAYER-001) | Pass | One central presenter and existing pages remain the implementation seam |
|
||||||
|
| Badge semantics (BADGE-001) | Pass | Existing freshness, completeness, publication-readiness, and artifact-truth badge domains stay canonical |
|
||||||
|
| Filament-native UI (UI-FIL-001) | Pass | Existing Filament resources, pages, badges, tables, and infolists are reused; no page-local status language is added |
|
||||||
|
| UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001) | Pass | Evidence snapshot, tenant review, and review pack remain `CRUD / List-first Resource` surfaces with dedicated detail pages, while evidence overview and review register remain `Read-only Registry / Report Surface` surfaces |
|
||||||
|
| UI/UX inspect model (UI-HARD-001) | Pass | Clickable-row inspect remains primary on the affected lists; no redundant view action is introduced |
|
||||||
|
| UI/UX action hierarchy (UI-HARD-001 / UI-EX-001) | Pass | Existing one-inline-safe-shortcut patterns remain; no new row-level destructive actions are added |
|
||||||
|
| UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001) | Pass | The feature strengthens default-visible trust truth while preserving canonical nouns and existing verbs |
|
||||||
|
| List-surface review checklist (UI-STD-001) | Pass | The affected list and report surfaces will be reviewed against `docs/product/standards/list-surface-review-checklist.md` during implementation and final verification |
|
||||||
|
| Filament Action Surface Contract | Pass | Current surface declarations already match the required list/detail behavior; the feature changes truth semantics, not action topology |
|
||||||
|
| Filament UX-001 | Pass | Existing Infolist and table layouts remain; this slice strengthens the truth surfaces inside them |
|
||||||
|
| Filament v5 / Livewire v4 compliance | Pass | The work remains inside the current Filament v5 + Livewire v4 stack |
|
||||||
|
| Provider registration location | Pass | No panel/provider changes; Laravel 11+ provider registration remains in `bootstrap/providers.php` |
|
||||||
|
| Asset strategy | Pass | No new panel or shared assets are required; deployment `filament:assets` behavior remains unchanged |
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/174-evidence-freshness-publication-trust/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Reuse the existing source-derived stale semantics from evidence providers and `EvidenceCompletenessEvaluator` instead of creating a new global freshness engine.
|
||||||
|
- Tighten `ArtifactTruthPresenter` rather than introducing a new publication-trust resolver or freshness-trust gate.
|
||||||
|
- Degrade tenant review and review pack publication trust when freshness is stale and downgrade partial evidence to `internal_only` unless stronger existing blockers already make the artifact `blocked`.
|
||||||
|
- Make review packs inherit stale and partial burden from their linked review and evidence basis instead of treating pack freshness as `current` whenever the file itself is not expired.
|
||||||
|
- Keep canonical register and overview surfaces aligned by reusing the same truth envelope and next-step language rather than adding page-local taxonomies or ad-hoc columns.
|
||||||
|
- Expand existing Pest coverage and fixture builders rather than creating a new UI test harness.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/174-evidence-freshness-publication-trust/`:
|
||||||
|
|
||||||
|
- `data-model.md`: derived trust-propagation model for evidence snapshots, tenant reviews, review packs, and canonical summary rows
|
||||||
|
- `contracts/evidence-review-trust-surfaces.openapi.yaml`: internal page-contract schema for the affected rendered HTML surfaces and their structured truth payloads
|
||||||
|
- `quickstart.md`: focused verification workflow for manual and automated validation
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- No schema migration is required; freshness remains derived from existing evidence-source timestamps and summary state.
|
||||||
|
- The primary implementation seam is `ArtifactTruthPresenter`, supported by the current evidence and review readiness services and existing page row builders.
|
||||||
|
- `TenantReviewReadinessGate` remains the publish-blocker authority for required stale or missing sections; this feature tightens how that burden is translated into operator-facing trust and publication surfaces.
|
||||||
|
- `EvidenceOverview` and `ReviewRegister` continue to render read-only summary rows, but must no longer sound calmer than the corresponding tenant-scoped detail surfaces.
|
||||||
|
- Existing destructive actions and capabilities remain unchanged; only truth presentation, next-step guidance, and consistency rules are hardened.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/174-evidence-freshness-publication-trust/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── evidence-review-trust-surfaces.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ ├── Monitoring/
|
||||||
|
│ │ │ └── EvidenceOverview.php
|
||||||
|
│ │ └── Reviews/
|
||||||
|
│ │ └── ReviewRegister.php
|
||||||
|
│ └── Resources/
|
||||||
|
│ ├── EvidenceSnapshotResource.php
|
||||||
|
│ ├── ReviewPackResource.php
|
||||||
|
│ ├── TenantReviewResource.php
|
||||||
|
│ ├── EvidenceSnapshotResource/
|
||||||
|
│ │ └── Pages/
|
||||||
|
│ │ └── ViewEvidenceSnapshot.php
|
||||||
|
│ ├── ReviewPackResource/
|
||||||
|
│ │ └── Pages/
|
||||||
|
│ │ └── ViewReviewPack.php
|
||||||
|
│ └── TenantReviewResource/
|
||||||
|
│ └── Pages/
|
||||||
|
│ └── ViewTenantReview.php
|
||||||
|
├── Models/
|
||||||
|
│ ├── EvidenceSnapshot.php
|
||||||
|
│ ├── EvidenceSnapshotItem.php
|
||||||
|
│ ├── ReviewPack.php
|
||||||
|
│ └── TenantReview.php
|
||||||
|
├── Services/
|
||||||
|
│ ├── Evidence/
|
||||||
|
│ │ ├── EvidenceCompletenessEvaluator.php
|
||||||
|
│ │ ├── EvidenceSnapshotService.php
|
||||||
|
│ │ └── Sources/
|
||||||
|
│ │ ├── BaselineDriftPostureSource.php
|
||||||
|
│ │ ├── EntraAdminRolesSource.php
|
||||||
|
│ │ ├── FindingsSummarySource.php
|
||||||
|
│ │ ├── OperationsSummarySource.php
|
||||||
|
│ │ └── PermissionPostureSource.php
|
||||||
|
│ └── TenantReviews/
|
||||||
|
│ ├── TenantReviewReadinessGate.php
|
||||||
|
│ └── TenantReviewRegisterService.php
|
||||||
|
└── Support/
|
||||||
|
└── Ui/
|
||||||
|
└── GovernanceArtifactTruth/
|
||||||
|
├── ArtifactTruthEnvelope.php
|
||||||
|
└── ArtifactTruthPresenter.php
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
├── infolists/
|
||||||
|
│ └── entries/
|
||||||
|
│ └── governance-artifact-truth.blade.php
|
||||||
|
└── pages/
|
||||||
|
├── monitoring/
|
||||||
|
│ └── evidence-overview.blade.php
|
||||||
|
└── reviews/
|
||||||
|
└── review-register.blade.php
|
||||||
|
|
||||||
|
routes/
|
||||||
|
└── web.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Concerns/
|
||||||
|
│ │ └── BuildsGovernanceArtifactTruthFixtures.php
|
||||||
|
│ ├── Evidence/
|
||||||
|
│ │ ├── EvidenceOverviewPageTest.php
|
||||||
|
│ │ └── EvidenceSnapshotResourceTest.php
|
||||||
|
│ ├── Monitoring/
|
||||||
|
│ │ └── ArtifactTruthRunDetailTest.php
|
||||||
|
│ ├── ReviewPack/
|
||||||
|
│ │ └── ReviewPackResourceTest.php
|
||||||
|
│ └── TenantReview/
|
||||||
|
│ ├── TenantReviewLifecycleTest.php
|
||||||
|
│ └── TenantReviewRegisterTest.php
|
||||||
|
└── Pest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Standard Laravel monolith. The change is concentrated in one existing truth-presenter seam, current readiness helpers, a small set of existing Filament resources/pages, and focused Pest coverage. No new base directories, services, or presentation frameworks are required.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Preserve Source-Derived Freshness As The Canonical Input
|
||||||
|
|
||||||
|
**Goal**: Keep existing provider-level stale semantics as the source of truth and document them clearly in the implementation so the feature does not accidentally invent a second freshness system.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `app/Services/Evidence/EvidenceCompletenessEvaluator.php` | Confirm that source-level `stale` evaluation and snapshot completeness remain the canonical freshness input used by the UI truth layer |
|
||||||
|
| A.2 | `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` | Keep evidence-snapshot truth derived from snapshot status, completeness, and `summary.stale_dimensions` rather than introducing new page-local age logic |
|
||||||
|
| A.3 | `specs/174-evidence-freshness-publication-trust/research.md` and `data-model.md` | Record the source-derived freshness chain so implementation does not drift into a new threshold engine |
|
||||||
|
|
||||||
|
### Phase B — Downgrade Tenant Review Publication Trust When Freshness Or Completeness Weakens Confidence
|
||||||
|
|
||||||
|
**Goal**: Ensure tenant reviews can remain useful internally while no longer appearing publishable when their evidence basis is stale or partial.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` | Tighten `buildTenantReviewEnvelope()` so stale freshness and partial evidence degrade publication readiness and next-step guidance appropriately |
|
||||||
|
| B.2 | `app/Services/TenantReviews/TenantReviewReadinessGate.php` | Reuse existing required-section stale and missing blocker semantics; do not create a second publish-blocker system |
|
||||||
|
| B.3 | `app/Filament/Resources/TenantReviewResource.php` and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` | Verify the tightened truth envelope is visible on list and detail surfaces without changing action topology or copy vocabulary |
|
||||||
|
|
||||||
|
### Phase C — Propagate Source Review And Evidence Burden Into Review Pack Trust
|
||||||
|
|
||||||
|
**Goal**: Prevent review packs from appearing calmer than the review or evidence basis they were generated from.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` | Tighten `buildReviewPackEnvelope()` so pack freshness and publication readiness inherit stale or partial burden from the source review and evidence resolution |
|
||||||
|
| C.2 | `app/Filament/Resources/ReviewPackResource.php` and `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php` | Ensure pack list/detail surfaces expose internal-only or cautionary trust and next-step caveats before download or sharing |
|
||||||
|
| C.3 | `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php` | Reuse the existing truth partial to surface freshness and publication dimensions with stronger consistency rather than new local markup |
|
||||||
|
|
||||||
|
### Phase D — Align Canonical Overview And Register Rows With Tenant-Scoped Detail Truth
|
||||||
|
|
||||||
|
**Goal**: Ensure `/admin/evidence/overview` and `/admin/reviews` summarize the same truth direction as the tenant-scoped detail pages.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `app/Filament/Pages/Monitoring/EvidenceOverview.php` | Keep row truth and next-step guidance sourced from the same envelope semantics used by snapshot detail |
|
||||||
|
| D.2 | `app/Filament/Pages/Reviews/ReviewRegister.php` | Keep artifact truth, publication readiness, and next-step rows aligned with tenant review detail without adding new ad-hoc row taxonomy |
|
||||||
|
| D.3 | `routes/web.php` and current navigation helpers | Preserve existing canonical route shape and tenant-prefilter continuity; no routing change |
|
||||||
|
|
||||||
|
### Phase E — Regression Protection And Focused Validation
|
||||||
|
|
||||||
|
**Goal**: Add the smallest useful test set that protects stale propagation, partial-evidence trust, review/pack consistency, and canonical summary alignment.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php` | Add fixture helpers for stale and partial evidence scenarios so tests do not duplicate setup logic |
|
||||||
|
| E.2 | `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` | Add fresh-vs-stale snapshot truth assertions, including stale-dimension next-step behavior |
|
||||||
|
| E.3 | `tests/Feature/TenantReview/TenantReviewLifecycleTest.php` | Add stale and partial review publication-trust assertions without changing lifecycle behavior |
|
||||||
|
| E.4 | `tests/Feature/ReviewPack/ReviewPackResourceTest.php` | Add pack trust propagation assertions for stale or partial source review/evidence combinations |
|
||||||
|
| E.5 | `tests/Feature/TenantReview/TenantReviewRegisterTest.php` and `tests/Feature/Evidence/EvidenceOverviewPageTest.php` | Add canonical row-alignment assertions so summary pages do not sound calmer than detail |
|
||||||
|
| E.6 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before task completion |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D-001 — Source-specific stale evaluation stays canonical
|
||||||
|
|
||||||
|
Evidence sources already produce `stale` item states using their own domain-appropriate recency logic, and `EvidenceCompletenessEvaluator` already rolls those into snapshot completeness. The plan reuses that chain instead of inventing a separate global age-policy system.
|
||||||
|
|
||||||
|
### D-002 — `ArtifactTruthPresenter` remains the single trust-propagation seam
|
||||||
|
|
||||||
|
The current code already centralizes evidence snapshot, tenant review, and review pack trust in one presenter. Tightening that presenter is narrower and safer than introducing a new publication-trust resolver or freshness framework.
|
||||||
|
|
||||||
|
### D-003 — Publication readiness remains distinct from freshness and completeness
|
||||||
|
|
||||||
|
The feature must not collapse all trust dimensions into one vague `ready` state. Freshness, completeness, and publication readiness remain separate dimensions, but stale evidence downgrades share safety and partial evidence downgrades publication readiness to `internal_only` unless existing blockers already make the artifact `blocked`.
|
||||||
|
|
||||||
|
### D-004 — Canonical summary pages must reuse existing truth, not invent row-local semantics
|
||||||
|
|
||||||
|
`EvidenceOverview` and `ReviewRegister` already surface artifact truth, publication, and next-step information. The right fix is to align their inputs with the tightened truth envelope, not to add page-specific badges or prose.
|
||||||
|
|
||||||
|
### D-005 — No new persistence or reporting subsystem is justified
|
||||||
|
|
||||||
|
This is a current-release operator-trust problem, but it is still a derived-truth problem. Existing timestamps, summary state, and linked artifacts are enough to solve it without a StoredReport viewer, new export-governance model, or separate publication-trust entity.
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| Review publication trust becomes too strict and downgrades healthy reviews | Medium | Medium | Base downgrades on existing stale/partial semantics already produced by evidence and review services rather than a new heuristic |
|
||||||
|
| Review pack truth diverges from review truth because pack code and review code evolve separately | High | Medium | Centralize propagation in `ArtifactTruthPresenter` and add explicit review-vs-pack consistency tests |
|
||||||
|
| Canonical summary pages become denser or harder to scan | Medium | Low | Reuse existing columns and next-step fields instead of adding more row furniture |
|
||||||
|
| `fresh` versus non-`fresh` envelope variants diverge across surfaces | Medium | Low | Keep one truth-building path and only use `fresh` variants when cache bypass is genuinely needed |
|
||||||
|
| Implementation accidentally introduces a new freshness policy or new abstraction | Medium | Low | Lock the design to current source-derived stale semantics and reject new persistence or resolver additions in review |
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
- Extend the current evidence, tenant review, review pack, and canonical summary Pest tests instead of adding a new testing layer.
|
||||||
|
- Use `BuildsGovernanceArtifactTruthFixtures` and existing `composeTenantReviewForTest()` helpers to build fresh, stale, and partial scenarios consistently.
|
||||||
|
- Add explicit assertions for fresh versus stale evidence snapshots, partial versus complete review evidence, review-pack versus review trust alignment, and canonical row truth alignment.
|
||||||
|
- Preserve existing authorization semantics: non-entitled users remain `404`, in-scope users without manage capability remain `403` for actions, and summary truth remains visible only within entitled scope.
|
||||||
|
- Keep destructive actions and operation-launch semantics unchanged; test additions should focus on trust consequences, not on unrelated lifecycle behavior.
|
||||||
|
- Focused verification targets: `EvidenceSnapshotResourceTest`, `TenantReviewLifecycleTest`, `ReviewPackResourceTest`, `TenantReviewRegisterTest`, `EvidenceOverviewPageTest`, and fixture helpers.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations or justified complexity exceptions were identified.
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
Not triggered beyond the already-passed spec review. The plan introduces no new enum/status family, DTO/presenter family, persisted entity, registry, resolver, taxonomy, or cross-domain UI framework.
|
||||||
141
specs/174-evidence-freshness-publication-trust/quickstart.md
Normal file
141
specs/174-evidence-freshness-publication-trust/quickstart.md
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# Quickstart: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Validate that stale and partial evidence now degrade review and pack trust consistently across tenant-scoped detail pages and canonical summary pages, without changing routes, authorization semantics, or the current action inventory.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start Sail if it is not already running.
|
||||||
|
2. Ensure the acting user is a valid workspace member and entitled to the target tenant or tenants.
|
||||||
|
3. Prepare representative fixtures for these cases:
|
||||||
|
- fresh and complete evidence snapshot
|
||||||
|
- stale evidence snapshot
|
||||||
|
- partial evidence snapshot or review with partial sections
|
||||||
|
- review with publication blockers
|
||||||
|
- ready review pack derived from a fresh review
|
||||||
|
- ready review pack derived from a stale or partial review
|
||||||
|
|
||||||
|
## Focused Automated Verification
|
||||||
|
|
||||||
|
Run the smallest existing test set that guards the affected surfaces first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewLifecycleTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackResourceTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewRegisterTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceOverviewPageTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
If implementation extends shared truth helpers or run-detail truth copy, also run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Validation Pass
|
||||||
|
|
||||||
|
Use one fresh tenant and one stale or partial tenant, or equivalent seeded records in the same tenant.
|
||||||
|
|
||||||
|
### 1. Evidence snapshot detail trust
|
||||||
|
|
||||||
|
Open the tenant-scoped evidence snapshot detail page for a fresh snapshot and a stale snapshot.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- both snapshots can still exist structurally,
|
||||||
|
- the stale snapshot is clearly shown as stale or cautionary,
|
||||||
|
- the fresh snapshot remains current,
|
||||||
|
- next-step guidance differs appropriately,
|
||||||
|
- and no raw JSON is required to understand why the stale snapshot is less trustworthy.
|
||||||
|
|
||||||
|
### 2. Review detail trust
|
||||||
|
|
||||||
|
Open tenant reviews anchored to:
|
||||||
|
|
||||||
|
- fresh complete evidence,
|
||||||
|
- stale evidence,
|
||||||
|
- partial evidence.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- review freshness and publication readiness remain separate concepts,
|
||||||
|
- stale or partial reviews can still be useful internally,
|
||||||
|
- but they do not present the same calm publishable posture as fresh complete reviews,
|
||||||
|
- and next-step guidance points toward refresh or completion work when needed.
|
||||||
|
|
||||||
|
### 3. Review pack trust
|
||||||
|
|
||||||
|
Open the review pack list and review packs linked to the reviews above.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the review-pack list row already surfaces the same internal-only or publishable caveat before the operator opens detail or clicks `Download`,
|
||||||
|
- a pack generated from stale or partial evidence no longer looks calmer than the source review,
|
||||||
|
- any internal-only or cautionary posture is visible before download,
|
||||||
|
- and the pack points back to the source review when corrective action is needed.
|
||||||
|
|
||||||
|
### 4. Canonical summary alignment
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
- `/admin/evidence/overview`
|
||||||
|
- `/admin/reviews`
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- stale or partial artifacts are visible as such in the row summaries,
|
||||||
|
- the next-step language is directionally the same as on detail pages,
|
||||||
|
- a tenant with fresh evidence but no current review shows a next step that points toward review creation rather than implying review readiness already exists,
|
||||||
|
- and drill-through links preserve tenant context and do not reveal non-entitled tenants.
|
||||||
|
|
||||||
|
### 5. Ten-second scan validation
|
||||||
|
|
||||||
|
Timebox the first visible scan of one snapshot detail page, one tenant review detail page, and one review pack detail page to 10 seconds each.
|
||||||
|
|
||||||
|
Confirm that within that time an operator can tell:
|
||||||
|
|
||||||
|
- whether the artifact is fresh enough,
|
||||||
|
- whether it is only internally useful or publishable,
|
||||||
|
- and what the next action is.
|
||||||
|
|
||||||
|
### 6. Authorization and action non-regression
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- view-only users can still inspect truth but not execute manage actions,
|
||||||
|
- non-entitled users still receive deny-as-not-found behavior,
|
||||||
|
- existing destructive actions still require confirmation,
|
||||||
|
- touched refresh, publish, export, regenerate, or create-next-review handlers still dispatch the existing services and current `OperationRun` types where applicable,
|
||||||
|
- and no new actions or route changes were introduced as part of the hardening.
|
||||||
|
|
||||||
|
### 7. Shared list-surface review checklist
|
||||||
|
|
||||||
|
Review `docs/product/standards/list-surface-review-checklist.md` against the touched list and report surfaces.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- `/admin/evidence/overview` and `/admin/reviews` still use row click as the primary inspect model,
|
||||||
|
- the tenant-scoped review-pack list keeps its row-level trust caveat visible before drill-through or download,
|
||||||
|
- existing inline safe shortcuts and header actions remain in their established positions,
|
||||||
|
- empty and filtered states still read clearly without hiding trust truth,
|
||||||
|
- and default-visible row summaries still surface freshness, publishable posture, and next step without requiring drill-through.
|
||||||
|
|
||||||
|
### 8. Performance and render guardrails
|
||||||
|
|
||||||
|
Confirm from the implementation diff and final surface behavior that:
|
||||||
|
|
||||||
|
- the touched detail and canonical surfaces still render from existing database-backed truth inputs,
|
||||||
|
- no new render-time external calls, background dispatches, or route changes were introduced,
|
||||||
|
- and canonical row truth remains lightweight enough for a normal operator scan without adding a new per-row derivation layer.
|
||||||
|
|
||||||
|
## Formatting And Final Verification
|
||||||
|
|
||||||
|
Before finalizing implementation work:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rerun the smallest affected test set. If the user wants broader confidence afterward, offer the full suite.
|
||||||
60
specs/174-evidence-freshness-publication-trust/research.md
Normal file
60
specs/174-evidence-freshness-publication-trust/research.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Research: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
## Decision 1: Reuse the existing source-derived stale semantics instead of introducing a global freshness engine
|
||||||
|
|
||||||
|
- Decision: Treat provider-level `stale` evaluation and the existing `EvidenceCompletenessEvaluator` as the canonical source of temporal freshness for this feature.
|
||||||
|
- Rationale: Evidence freshness is already derived upstream in the evidence domain. `EvidenceSnapshotService` collects per-dimension payloads, providers such as `BaselineDriftPostureSource` already mark data stale using domain-appropriate recency rules, and `EvidenceCompletenessEvaluator` already escalates any required stale dimension into snapshot completeness. A second global threshold engine would duplicate truth and risk disagreement with the evidence domain.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Add a new global `freshness_threshold_hours` configuration and recompute staleness on the page layer. Rejected because the existing evidence sources already encode domain-aware freshness and the page layer should not create its own recency semantics.
|
||||||
|
- Derive staleness purely from `generated_at` on snapshots and reviews. Rejected because the meaningful freshness signal already exists at the dimension level, and a snapshot can be freshly generated from stale inputs.
|
||||||
|
|
||||||
|
## Decision 2: Tighten `ArtifactTruthPresenter` instead of adding a new trust or publication resolver
|
||||||
|
|
||||||
|
- Decision: Keep `ArtifactTruthPresenter` as the single seam for evidence snapshot, tenant review, and review pack trust propagation.
|
||||||
|
- Rationale: The presenter already builds envelopes for `EvidenceSnapshot`, `TenantReview`, and `ReviewPack`, including freshness, content, publication, actionability, and next-step guidance. The current bug is not the absence of a trust layer, but that stale and partial semantics are not fully propagated through the existing one.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Create a dedicated `PublicationReadinessResolver` or `FreshnessTrustGate`. Rejected because the current architecture already centralizes the needed truth and a second layer would violate the repo's proportionality and few-layers rules.
|
||||||
|
- Push the logic into page classes. Rejected because it would fragment truth across summary pages and detail resources.
|
||||||
|
|
||||||
|
## Decision 3: Tenant review publication readiness must degrade when freshness is stale
|
||||||
|
|
||||||
|
- Decision: Make stale review freshness capable of downgrading a review from publishable to an internal-only or cautionary posture even when the review status is `ready` or `published`.
|
||||||
|
- Rationale: `TenantReviewReadinessGate` already treats stale required sections as publication blockers at composition time, and `buildTenantReviewEnvelope()` already calculates a `freshnessState` of `stale`. The current contradiction is that publication readiness still becomes `publishable` from status alone, which lets stale reviews sound calmer than the evidence basis justifies.
|
||||||
|
- Rule: A stale review may remain internally useful, but it must not remain `publishable` solely because its lifecycle status is `ready` or `published`. If stronger blockers already exist, `blocked` still takes precedence.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Leave publication readiness based only on status and blocker arrays. Rejected because it allows a stale review to look publishable even when the same presenter calls it stale.
|
||||||
|
- Introduce a new persisted review trust field. Rejected because the stale condition is already derivable from review completeness and section counts.
|
||||||
|
|
||||||
|
## Decision 4: Partial evidence must lower publication confidence even when it does not fully block review use
|
||||||
|
|
||||||
|
- Decision: Distinguish between reviews that are usable internally and reviews that are truly publishable when the evidence basis is partial.
|
||||||
|
- Rationale: The spec requires a meaningful difference between internal-use artifacts and publishable artifacts. The current truth model already supports separate `contentState`, `freshnessState`, `publicationReadiness`, and `actionability` dimensions, so the implementation can express this nuance without new state families.
|
||||||
|
- Rule: Partial evidence downgrades publication readiness to `internal_only` unless stronger existing blockers already make the artifact `blocked`.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Treat partial evidence exactly like complete evidence whenever hard blockers are absent. Rejected because that is the current trust problem.
|
||||||
|
- Convert all partial evidence into hard publish blockers. Rejected because the spec wants internal-use versus publishable to remain distinct, not collapsed into blocked versus not blocked.
|
||||||
|
|
||||||
|
## Decision 5: Review packs must inherit stale and partial burden from the linked review and evidence basis
|
||||||
|
|
||||||
|
- Decision: Make `ReviewPack` truth inherit source review and evidence burden rather than treating a ready, non-expired file as inherently current and publishable.
|
||||||
|
- Rationale: `buildReviewPackEnvelope()` currently treats pack freshness as `current` whenever the file is not expired, even if the source review or source evidence is stale. That allows packs to appear calmer than the review they were generated from, which is exactly the contradiction this spec is meant to close.
|
||||||
|
- Rule: A pack is only `publishable` when its source review remains current and strong enough to publish. Stale or partial source burden must downgrade the pack to `internal_only`, unless stronger blockers already make it `blocked`.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Keep pack freshness tied only to file expiration. Rejected because a current file can still be generated from stale governance evidence.
|
||||||
|
- Persist a separate pack trust state. Rejected because the necessary source review and evidence inputs already exist.
|
||||||
|
|
||||||
|
## Decision 6: Canonical summary pages should reuse the same truth envelope and next-step semantics
|
||||||
|
|
||||||
|
- Decision: Keep `EvidenceOverview` and `ReviewRegister` aligned by reusing the same envelope semantics that power the tenant-scoped detail pages.
|
||||||
|
- Rationale: Both canonical pages already display artifact truth and next-step or publication surfaces. The current risk is not missing UI slots, but summary rows sounding calmer than the detail pages because stale and partial burden are not fully represented in the underlying truth.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Add new page-specific columns, banners, or taxonomy labels. Rejected because the repo already has canonical truth and badge primitives.
|
||||||
|
- Leave canonical summary rows untouched and rely on drill-through. Rejected because the spec explicitly targets trust propagation on summary surfaces.
|
||||||
|
|
||||||
|
## Decision 7: Expand the current Pest test surfaces and fixture helpers instead of creating a new test harness
|
||||||
|
|
||||||
|
- Decision: Extend the current evidence, review, pack, review-register, and evidence-overview tests, and strengthen shared governance-artifact fixtures.
|
||||||
|
- Rationale: The current suite already covers resource routing, basic artifact truth visibility, review publication blockers, and canonical row scoping. The missing coverage is specific: fresh versus stale propagation, partial-evidence publication trust, and review-versus-pack consistency.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Rely on manual browser validation only. Rejected because this feature is about preventing semantic drift across multiple related surfaces.
|
||||||
|
- Add a separate browser suite as the primary guard. Rejected because the existing Pest feature surfaces are already well aligned with the affected code paths and will be faster and cheaper to maintain.
|
||||||
237
specs/174-evidence-freshness-publication-trust/spec.md
Normal file
237
specs/174-evidence-freshness-publication-trust/spec.md
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
# Feature Specification: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
**Feature Branch**: `174-evidence-freshness-publication-trust`
|
||||||
|
**Created**: 2026-04-04
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Spec 174 — Evidence Temporal Freshness & Review Publication Trust"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant + canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/t/{tenant}/evidence` and `/admin/t/{tenant}/evidence/{snapshot}` for tenant-scoped evidence snapshot inspection
|
||||||
|
- `/admin/t/{tenant}/reviews` and `/admin/t/{tenant}/reviews/{review}` for tenant-scoped review lifecycle and publication decisions
|
||||||
|
- `/admin/t/{tenant}/review-packs` and `/admin/t/{tenant}/review-packs/{pack}` for tenant-scoped executive pack generation, download, and trust communication
|
||||||
|
- `/admin/reviews` for the canonical review register across entitled tenants
|
||||||
|
- `route('admin.evidence.overview')` for the canonical evidence overview across entitled tenants
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Tenant-owned: stored reports, evidence snapshots, evidence snapshot items, tenant reviews, and review packs remain tenant-scoped artifacts anchored to one tenant's governance history
|
||||||
|
- Workspace-owned but tenant-filtered: canonical register and overview pages aggregate only within the operator's entitled workspace and tenant set, without changing ownership of the underlying artifacts
|
||||||
|
- This feature introduces no new persisted trust record, no new freshness record, and no new export-tracking entity; truth remains derived from existing timestamps, completeness states, and artifact links
|
||||||
|
- **RBAC**:
|
||||||
|
- Workspace membership and tenant entitlement remain required for all tenant-scoped evidence, review, and pack routes
|
||||||
|
- Existing capabilities remain authoritative: `evidence.view` / `evidence.manage`, `tenant_review.view` / `tenant_review.manage`, and `review_pack.view` / `review_pack.manage`
|
||||||
|
- Canonical review and evidence surfaces must preserve deny-as-not-found semantics for non-members and non-entitled users, and must not expose cross-tenant artifact existence, trust posture, or readiness hints outside the authorized tenant set
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: When an operator opens the canonical review register or canonical evidence overview from a tenant-scoped surface, the destination opens prefiltered to that tenant through the existing tenant-prefilter mechanisms so the operator stays in the same tenant world they clicked from.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical review rows, evidence rows, artifact-truth badges, publication-readiness badges, next-step text, filters, and drill-through links must only be built after workspace membership and tenant-entitlement checks. Non-entitled users must not learn whether another tenant has a current review, a stale evidence snapshot, or an exportable pack.
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
Affected list and report surfaces in this spec MUST also be reviewed against `docs/product/standards/list-surface-review-checklist.md` before implementation sign-off so row-click behavior, inline action discipline, empty states, and summary truth remain aligned with the shared operator-surface standard.
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Evidence snapshot resource | CRUD / List-first Resource | Full-row click from list to snapshot detail | required | One `More` menu for non-primary list actions | `Expire snapshot` in `More` or detail header | `/admin/t/{tenant}/evidence` | `/admin/t/{tenant}/evidence/{snapshot}` | Tenant context and evidence completeness badges scope every row and action | Evidence / Evidence snapshot | Artifact truth, completeness, freshness pressure, and next step | none |
|
||||||
|
| Tenant review resource | CRUD / List-first Resource | Full-row click from list to tenant review detail | required | One inline safe shortcut (`Export executive pack`) plus detail-header support actions | `Archive review` in detail header `More` group | `/admin/t/{tenant}/reviews` | `/admin/t/{tenant}/reviews/{review}` | Tenant context, review status, completeness, publication readiness, and evidence basis stay explicit | Reviews / Review | Artifact truth, completeness, publication readiness, and next step | none |
|
||||||
|
| Canonical review register | Read-only Registry / Report Surface | Clickable row to the tenant-scoped review detail that matches the row | required | One inline safe shortcut (`Export executive pack`) plus header clear-filters action | none | `/admin/reviews` | Tenant-scoped review detail for the selected row | Tenant labels, publication badges, completeness badges, and tenant-prefilter state | Reviews / Review | Cross-tenant review truth, publication readiness, and next step | canonical-view registry |
|
||||||
|
| Review pack resource | CRUD / List-first Resource | Full-row click from list to pack detail | required | One inline safe shortcut (`Download`) plus detail-header support actions | `Expire` in overflow or detail header | `/admin/t/{tenant}/review-packs` | `/admin/t/{tenant}/review-packs/{pack}` | Tenant context, linked review, review status, pack status, and artifact truth remain visible | Review Packs / Review pack | Publication readiness, freshness burden, and whether export is internal-only or publishable | none |
|
||||||
|
| Evidence overview | Read-only Registry / Report Surface | Clickable row to the tenant-scoped evidence snapshot detail for the selected tenant | required | Header clear-filters action only | none | `route('admin.evidence.overview')` | `/admin/t/{tenant}/evidence/{snapshot}` for the selected row | Tenant labels, artifact truth, freshness badges, and next-step text keep the overview scoped | Evidence / Evidence snapshot | Freshness and completeness truth across entitled tenants | canonical-view registry |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Evidence snapshot detail | Tenant operator | Detail-first Operational Surface | Is this evidence basis complete enough, fresh enough, and trustworthy enough to use right now? | Artifact truth, completeness state, freshness pressure, missing or stale dimensions, and next-step guidance | Raw summary JSON, deep dimension payloads, fingerprints, source record IDs | artifact existence, content completeness, temporal freshness, actionability | TenantPilot evidence lifecycle only | Refresh evidence, view linked operation, inspect dimensions | Expire snapshot |
|
||||||
|
| Tenant review detail | Tenant operator | Detail-first Operational Surface | Is this review merely stored, ready for internal use, or strong enough to publish externally? | Artifact truth, completeness, publication readiness, review blockers or warnings, evidence basis, and next-step guidance | Full section payloads, raw summary JSON, historical audit context, operation metadata | artifact existence, completeness, temporal freshness, publication readiness, actionability | TenantPilot review lifecycle and export initiation | Refresh review, publish review, export executive pack, create next review, open evidence | Archive review |
|
||||||
|
| Canonical review register | Workspace auditor or operator | Read-only Registry / Report Surface | Which tenants currently have review artifacts that are fresh enough, publication-ready enough, or in need of follow-up? | Review status, artifact truth, completeness, publication readiness, and next-step text per tenant | Deep section content, raw evidence payloads, audit internals | review lifecycle, artifact truth, completeness, publication readiness | Read-only registry plus export initiation where already allowed | Open review, export executive pack | none |
|
||||||
|
| Review pack detail | Tenant operator | Detail-first Operational Surface | Is this pack merely downloadable, or is it publishable enough to treat as a stakeholder-facing export? | Artifact truth, pack status, linked review, evidence basis summary, publication readiness, and download caveat when needed | Fingerprints, previous fingerprint, low-level generation metadata | artifact existence, freshness pressure, publication readiness, expiration state | TenantPilot export lifecycle only | Download, regenerate pack, open linked review | Expire pack |
|
||||||
|
| Evidence overview | Workspace auditor or operator | Read-only Registry / Report Surface | Which entitled tenants currently have fresh, complete evidence and which require follow-up before review or publication? | Tenant, artifact truth, freshness state, missing or stale dimensions, and next step | Per-dimension detail payloads and raw summary JSON remain on snapshot detail | artifact truth, freshness, completeness | none | Open evidence snapshot | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No. Evidence snapshots, review records, review packs, and the existing artifact-truth layer remain the authoritative truth sources.
|
||||||
|
- **New persisted entity/table/artifact?**: No. This feature explicitly avoids new persistence and must derive freshness and publication trust from existing timestamps, completeness states, and links.
|
||||||
|
- **New abstraction?**: No. The narrowest correct implementation is to harden the existing `ArtifactTruthPresenter`, readiness gates, and surface mappings rather than adding a second trust framework.
|
||||||
|
- **New enum/state/reason family?**: No. Existing freshness and publication-readiness dimensions should be tightened, not replaced with a new persisted status family.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No. This is a surface-truth hardening slice inside the existing evidence/review/export chain.
|
||||||
|
- **Current operator problem**: Structurally complete evidence can currently read as current enough and publishable enough even when it is too old or only partial, which risks operators exporting or publishing artifacts that are not decision-grade.
|
||||||
|
- **Existing structure is insufficient because**: Completeness and publication surfaces already exist, but temporal freshness and partial-evidence burden are not strong enough in the artifact-truth and review-readiness chain to keep surfaces from sounding calmer than the underlying evidence basis.
|
||||||
|
- **Narrowest correct implementation**: Tighten the existing artifact-truth and readiness evaluation to degrade stale or partial artifacts visibly on evidence, review, register, and pack surfaces, while preserving current resources and actions.
|
||||||
|
- **Ownership cost**: The repo takes on a small amount of additional cross-surface regression coverage and ongoing maintenance for freshness-threshold and publication-caveat rules.
|
||||||
|
- **Alternative intentionally rejected**: A new StoredReport resource, a new export governance table, or a separate portfolio-trust layer was rejected because the immediate product risk can be solved by tightening the current evidence/review/export truth chain.
|
||||||
|
- **Release truth**: Current-release truth. Operators can already act on these surfaces today, so trust hardening cannot wait for a later reporting overhaul.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Detect Stale Evidence Before Reusing It (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want evidence, reviews, and packs to tell me when their underlying evidence basis is too old to rely on confidently, so that I do not make governance decisions on structurally complete but outdated artifacts.
|
||||||
|
|
||||||
|
**Why this priority**: Silent evidence aging is the highest trust risk in this domain because it can make calm-looking artifacts unsafe for real decisions.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding fresh and old evidence snapshots with the same structural completeness and verifying that only the fresh artifacts read as current enough for confident reuse.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an evidence snapshot is structurally complete but one or more required evidence dimensions have aged past their canonical source-defined freshness thresholds, **When** an operator opens the snapshot, linked review, or linked pack surface, **Then** the artifact is shown as stale or cautionary rather than quietly current.
|
||||||
|
2. **Given** an evidence snapshot is structurally complete and still within the freshness threshold, **When** an operator opens the snapshot, linked review, or linked pack surface, **Then** the freshness dimension allows the artifact to remain in a current or fresher state when no other trust limiter applies.
|
||||||
|
3. **Given** a canonical register or overview lists both fresh and stale artifacts, **When** the operator scans the table, **Then** freshness burden is visible without opening every row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Distinguish Internal-Use Reviews From Publishable Reviews (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want review and pack surfaces to distinguish between internally useful artifacts and truly publishable artifacts, so that I do not treat downloadability or technical readiness as proof that a report is publishable.
|
||||||
|
|
||||||
|
**Why this priority**: Export and publication are the moments where a misleadingly calm artifact becomes an external trust problem.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering reviews and packs built from complete evidence, partial evidence, and stale evidence, then verifying that their readiness and publication signals diverge appropriately.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant review is structurally ready but built on partial evidence, **When** the operator views the review, **Then** the surface shows a cautionary or internal-only publication posture rather than the same calm signal used for publishable reviews.
|
||||||
|
2. **Given** a review pack is downloadable but derived from stale or partial evidence, **When** the operator opens or downloads the pack, **Then** the surface discloses that the pack is better suited to internal use or review refresh rather than quiet external sharing.
|
||||||
|
3. **Given** a tenant review and its current export pack share the same stale or partial evidence burden, **When** the operator compares their surfaces, **Then** both surfaces communicate consistent trust and publication signals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Recover The Evidence Basis Across Review Surfaces (Priority: P2)
|
||||||
|
|
||||||
|
As a workspace operator reviewing multiple tenants, I want canonical review and evidence surfaces to preserve the link between review status, evidence freshness, and next action, so that I can tell which tenants are review-ready and which need evidence refresh before publication.
|
||||||
|
|
||||||
|
**Why this priority**: Portfolio-level review work depends on summary surfaces carrying the same truth as the detail surfaces they summarize.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding tenants with mixed evidence freshness and review readiness and verifying that review-register and evidence-overview rows present the same trust direction as the underlying detail pages.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** two tenants have reviews with different evidence freshness burdens, **When** the operator scans the review register, **Then** the table communicates which review is fresher and which needs follow-up.
|
||||||
|
2. **Given** a tenant has fresh evidence but no current review, **When** the operator scans canonical surfaces, **Then** the surfaces show a next step that points toward review creation rather than implying review readiness already exists.
|
||||||
|
3. **Given** a tenant has stale evidence and a previously generated pack, **When** the operator follows drill-through links between overview and detail pages, **Then** the trust explanation remains coherent across the journey.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A run can complete and create an evidence snapshot, but the snapshot can later age past the acceptable freshness window without any structural completeness change.
|
||||||
|
- A review can have all sections present yet still rely on partial sections whose evidence basis should reduce publication confidence.
|
||||||
|
- A pack can remain downloadable after its linked review or evidence basis becomes stale; the surface must warn rather than silently remain calm.
|
||||||
|
- A stored report referenced by an evidence item can be pruned by retention while the evidence snapshot still exists; this feature must not require a new raw-report viewer to remain truthful.
|
||||||
|
- A user may be entitled to a tenant review but not to export or manage it; readiness and trust truth may still be visible, while actions remain capability-gated.
|
||||||
|
- Canonical register and overview pages may be prefiltered from a tenant context; they must remain semantically consistent without leaking artifacts from non-entitled tenants.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls and no new long-running flow. It hardens how existing evidence, review, and pack artifacts communicate trust. Existing review and pack actions continue to use their current services and current `OperationRun` types. No new contract registry entry or queued process is added.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This slice adds no persistence, no new abstraction layer, and no new persisted status family. The current operator problem is that structurally complete artifacts can look newer and safer than they are. Existing structure is insufficient because completeness and publication surfaces are not yet hard enough against age and partial evidence. The narrowest correct implementation is to strengthen the existing artifact-truth and readiness logic. Ownership cost is limited to regression coverage and threshold maintenance. A separate reporting or StoredReport framework is intentionally rejected in this slice.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** No new `OperationRun` type is added. Existing evidence snapshot generation, review composition, review refresh, and review-pack generation continue to own execution lifecycle through current services and current run types. This feature only changes how resulting artifacts communicate trust and next action after those runs complete.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The feature affects the tenant/admin plane for tenant-scoped evidence, reviews, and review packs, plus canonical admin pages for the review register and evidence overview. Non-members and non-entitled users remain `404`. In-scope members lacking `evidence.manage`, `tenant_review.manage`, or `review_pack.manage` remain `403` for the corresponding actions. Export and publication affordances must stay capability-aware while still allowing truth signals to appear for view-only users.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Existing centralized badge domains for tenant review status, tenant review completeness, evidence completeness, review pack status, governance artifact freshness, and related artifact-truth labels remain the semantic source. This feature may change when those badge families show caution or downgrade states, but must not introduce local page-only badge mappings.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The feature reuses native Filament resources, infolists, tables, actions, badges, sections, and existing governance artifact-truth entry views. Local replacement markup for core truth surfaces should be avoided. Any stronger stale or internal-only emphasis should still be expressed through Filament props and central badge or truth primitives rather than page-local color languages.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target objects are evidence snapshots, reviews, and review packs. Primary operator-facing vocabulary remains `Create snapshot`, `Refresh evidence`, `Create review`, `Refresh review`, `Publish review`, `Export executive pack`, `Generate first pack`, `Download`, and `Expire`. New or revised copy must preserve the difference between `current`, `stale`, `partial`, `internal use`, `publication ready`, and `blocked`, and must avoid implementation-first wording such as `snapshot age rule`, `trust envelope`, or `derived state` in primary operator labels.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The affected surfaces consist of `CRUD / List-first Resource` lists for snapshots, reviews, and review packs, `Read-only Registry / Report Surface` pages for the canonical overview and register, and dedicated `Detail-first Operational Surface` pages for artifact inspection and action context. Each keeps one primary inspect model: row-click for snapshot, review, register, and pack tables; detail-header actions for lifecycle mutations; and diagnostic subsections for raw summaries. Critical truth visible by default must include artifact truth, freshness burden, completeness burden, publication readiness, and next step where relevant. This feature does not introduce redundant `View` affordances or move destructive actions out of their current safe placements.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Operator-first content on evidence, review, and pack surfaces must separate execution truth, artifact existence, completeness, freshness, and publication readiness rather than flattening them into one vague `ready` state. Diagnostics such as raw summary JSON, fingerprints, and low-level IDs remain secondary. Mutating actions continue to act on TenantPilot-managed artifacts only.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from persisted artifact status alone is insufficient because artifact trust depends on cross-cutting freshness and completeness context. This feature must strengthen the existing artifact-truth layer rather than adding a parallel presenter family. Tests must verify business consequences such as stale artifacts not appearing publishable, partial evidence downgrading publication trust, and review/pack surfaces staying consistent.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Evidence, review, and pack resources keep one primary inspect model per list, no redundant row-level view actions, and confirmation-gated destructive actions where applicable. Review register and evidence overview remain read-only registry surfaces. UI-FIL-001 remains satisfied with no new exception required.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** Existing detail screens remain sectioned Infolist-style pages. This feature strengthens the top-level truth and caveat zones on those screens rather than converting them into forms or custom layouts. Tables remain searchable, sortable, and filterable where already supported.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-174-001**: Evidence snapshots and every artifact derived from them MUST distinguish structural completeness from temporal freshness. A structurally complete artifact MUST NOT automatically read as current enough solely because all expected dimensions are present.
|
||||||
|
- **FR-174-002**: Temporal freshness MUST be evaluated from existing evidence timestamps using a clearly defined freshness policy for each evidence dimension. Where source-specific freshness thresholds are already canonical in the evidence domain, this feature MUST reuse them rather than inventing a second global threshold.
|
||||||
|
- **FR-174-003**: When one or more required evidence dimensions exceed their canonical freshness policy, the snapshot, any review anchored to it, and any review pack anchored to that review or snapshot MUST visibly degrade into a stale or cautionary trust state.
|
||||||
|
- **FR-174-004**: Stale evidence MUST influence the artifact-truth summary that operators see on evidence snapshot detail, tenant review detail, review register rows, review pack detail, review pack list rows, and evidence overview rows.
|
||||||
|
- **FR-174-005**: A tenant review or review pack based on stale evidence MUST NOT present the same calm `current`, `ready`, or publishable impression as a fresh artifact.
|
||||||
|
- **FR-174-006**: Partial evidence MUST materially affect review and pack trust. If a review or review pack is structurally complete but its evidence basis is partial, the surface MUST downgrade publication readiness to `internal_only`, unless existing stronger publish blockers already make it `blocked`, instead of using the same publishable posture used for stronger evidence.
|
||||||
|
- **FR-174-007**: Review readiness and publication readiness MUST remain visibly distinct. An artifact may be ready for internal inspection while still requiring caution or restriction for external sharing.
|
||||||
|
- **FR-174-008**: Download or export affordances for review packs MUST surface when a pack is safer for internal use than for external sharing. A technically downloadable pack MUST NOT silently imply publishable trust.
|
||||||
|
- **FR-174-009**: Review and pack surfaces MUST explain the principal reason for trust reduction when freshness or evidence quality lowers confidence, including stale evidence, partial sections, or missing or stale dimensions where relevant.
|
||||||
|
- **FR-174-010**: When an artifact is stale, partial, internal-only, or otherwise not publishable, the surface MUST provide a clear next step such as refreshing evidence, refreshing the review, resolving missing evidence, or generating a new pack.
|
||||||
|
- **FR-174-011**: Canonical review and evidence registry surfaces MUST stay semantically aligned with their tenant-scoped detail surfaces. A row that reads stale, partial, or internal-only on a detail page MUST not read calm or publishable in its register row.
|
||||||
|
- **FR-174-012**: Tenant review detail and review pack detail MUST not send contradictory publication or trust signals for artifacts that share the same stale or partial evidence burden.
|
||||||
|
- **FR-174-013**: This feature MUST preserve existing tenant-scoped and canonical prefilter navigation semantics so operators can follow truth from snapshot to review to pack without losing tenant context.
|
||||||
|
- **FR-174-014**: The feature MUST not require a new StoredReport surface, a new persistence model, or a new export-governance artifact in order to become truthful about stale or partial evidence.
|
||||||
|
- **FR-174-015**: Existing evidence, review, and pack actions MUST keep their current capability checks, confirmation behavior, and run-launch semantics while the surrounding truth language becomes stricter.
|
||||||
|
- **FR-174-016**: Regression coverage MUST verify fresh vs stale evidence behavior, complete vs partial evidence behavior, review vs pack truth consistency, and download or export caveat behavior without relying on a new schema artifact.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Evidence snapshot resource | `app/Filament/Resources/EvidenceSnapshotResource.php` | `Create snapshot` on list header | `recordUrl()` clickable row to snapshot detail | `More` group currently carries `Expire snapshot` | none | `Create first snapshot` | `Refresh evidence`, `Expire snapshot`, existing related links | n/a | existing evidence lifecycle audit remains | This spec changes truth presentation and next-step guidance, not the action inventory |
|
||||||
|
| Tenant review resource | `app/Filament/Resources/TenantReviewResource.php` and `Pages/ViewTenantReview.php` | `Create review` on list header | `recordUrl()` clickable row to review detail | `Export executive pack` inline shortcut on list | none | `Create first review` | `Open operation`, `View executive pack`, `View evidence snapshot`, `Refresh review`, `Publish review`, `Export executive pack`, `Create next review`, `Archive review` | n/a | existing review lifecycle audit remains | Publication-trust hardening must apply consistently across list and detail |
|
||||||
|
| Canonical review register | `app/Filament/Pages/Reviews/ReviewRegister.php` | `Clear filters` | `recordUrl()` clickable row to tenant-scoped review detail | `Export executive pack` safe row action where currently allowed | none | `Clear filters` | n/a | n/a | no new audit behavior | Canonical registry remains read-only apart from the existing export shortcut |
|
||||||
|
| Review pack resource | `app/Filament/Resources/ReviewPackResource.php` | `Generate pack` on list header | `recordUrl()` clickable row to pack detail | `Download` | none | `Generate first pack` | `Download`, `Regenerate`, `Expire`, and existing related links | n/a | existing pack lifecycle audit remains | Download remains allowed where currently allowed, but its trust caveat must become explicit |
|
||||||
|
| Evidence overview | `app/Filament/Pages/Monitoring/EvidenceOverview.php` | `Clear filters` when tenant-prefilter is active | Clickable row to tenant-scoped evidence snapshot detail | none | none | `Clear filters` in the empty state | n/a | n/a | no new audit behavior | Read-only canonical overview; this spec makes freshness truth stronger here |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **StoredReport**: Raw tenant-scoped point-in-time source data such as permission posture or Entra admin roles captures. It is an input to evidence, not itself a review-grade artifact.
|
||||||
|
- **EvidenceSnapshot**: Immutable tenant-scoped evidence basis composed from multiple dimensions and evaluated for completeness and freshness.
|
||||||
|
- **EvidenceSnapshotItem**: A dimension-level evidence record linking an evidence snapshot to its source kind, source record, fingerprint, measured time, and completeness state.
|
||||||
|
- **TenantReview**: A review artifact anchored to one evidence snapshot and evaluated for completeness, publication readiness, and next action.
|
||||||
|
- **ReviewPack**: A tenant-scoped export artifact derived from a review or evidence basis, intended for download and potential sharing, with integrity and lifecycle metadata.
|
||||||
|
- **Artifact truth**: The operator-facing evaluation of artifact existence, completeness, freshness, publication readiness, and actionability that determines whether an artifact is merely stored, internally useful, or safe enough to publish.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-174-001**: In seeded review exercises, operators can determine within 10 seconds whether an evidence snapshot, tenant review, or review pack is fresh enough, stale, partial, or publishable without opening raw JSON or source records.
|
||||||
|
- **SC-174-002**: In regression coverage, every scenario built on stale evidence or partial evidence shows a more cautious trust or publication state than the equivalent fresh-and-complete scenario across snapshot, review, and pack surfaces.
|
||||||
|
- **SC-174-003**: In regression coverage, review-register rows, evidence-overview rows, tenant review detail, and review-pack detail agree on the trust direction of the same underlying artifact in 100% of covered stale and partial scenarios.
|
||||||
|
- **SC-174-004**: In operator validation, downloadable but internal-only or cautionary packs are recognizably different from publishable packs before the operator clicks `Download`.
|
||||||
|
- **SC-174-005**: The feature ships without a required schema migration, a new persisted trust artifact, or a new raw StoredReport resource.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing evidence snapshot timestamps and item timestamps are sufficient to derive temporal freshness without creating a new persistence model.
|
||||||
|
- Existing artifact-truth and review-readiness logic are the right places to strengthen stale and partial-evidence semantics.
|
||||||
|
- Existing review-pack download behavior can remain technically available while its trust and sharing guidance becomes more explicit.
|
||||||
|
- StoredReport observability and source-liveness follow-up work may still be useful later, but are not prerequisites for this truth-hardening slice.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Creating a dedicated StoredReport resource or raw report viewer
|
||||||
|
- Building a live-state-versus-historical-state diff engine for published reviews
|
||||||
|
- Adding stakeholder-delivery tracking or external-share audit semantics
|
||||||
|
- Rebuilding Evidence Overview or Review Register into a new portfolio application
|
||||||
|
- Introducing new tables, new status enums, or a second artifact-truth framework
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Existing `EvidenceSnapshotResource`, `TenantReviewResource`, `ReviewPackResource`, `ReviewRegister`, and `EvidenceOverview` surfaces
|
||||||
|
- Existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, review readiness logic, and review-pack generation services
|
||||||
|
- Existing capability and tenant-entitlement enforcement for evidence, reviews, and packs
|
||||||
|
- Existing evidence-domain, tenant-review, and review-pack foundational specs already implemented in the repo
|
||||||
|
|
||||||
|
## Follow-up Spec Candidates
|
||||||
|
|
||||||
|
- **StoredReport Observability & Source Liveness** for explicit raw-report diagnostics and pruned-source visibility
|
||||||
|
- **Evidence / Review Portfolio Cross-Linking** for tighter canonical navigation between evidence overview and review register
|
||||||
|
- **Published Review Drift & Superseded Evidence Signals** for signaling when a published review no longer reflects the current evidence basis
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Spec 174 is complete when:
|
||||||
|
|
||||||
|
- structurally complete artifacts no longer read as current enough when their evidence basis is too old,
|
||||||
|
- partial evidence produces a visibly more cautious review and pack trust posture,
|
||||||
|
- review and pack surfaces stay consistent about the same stale or partial burden,
|
||||||
|
- export and download actions no longer imply stronger trust than the underlying artifact supports,
|
||||||
|
- canonical register and overview surfaces reflect the same truth direction as tenant-scoped detail surfaces,
|
||||||
|
- and the hardening is achieved without new persistence or a new reporting subsystem.
|
||||||
226
specs/174-evidence-freshness-publication-trust/tasks.md
Normal file
226
specs/174-evidence-freshness-publication-trust/tasks.md
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
# Tasks: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/174-evidence-freshness-publication-trust/`
|
||||||
|
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`
|
||||||
|
|
||||||
|
**Tests**: Tests are REQUIRED for this feature. Use focused Pest coverage in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, `tests/Feature/TenantReview/TenantReviewRegisterTest.php`, `tests/Feature/ReviewPack/ReviewPackResourceTest.php`, and existing truth-support fixtures.
|
||||||
|
**Operations**: This feature reuses existing evidence snapshot, tenant review, and review pack generation flows and their current `OperationRun` types. No new `OperationRun` creation, lifecycle transition, notification, or `summary_counts` producer work is introduced.
|
||||||
|
**RBAC**: Existing tenant entitlement, workspace entitlement, and capability gating must remain unchanged. Tests must preserve deny-as-not-found behavior for non-entitled users and manage-action capability gating for view-only users.
|
||||||
|
**Operator Surfaces**: Evidence snapshot detail, tenant review detail, review pack detail, evidence overview, and the canonical review register must stay operator-first, with freshness, completeness, publication readiness, and next step visible by default where relevant.
|
||||||
|
**Filament UI Action Surfaces**: No new actions are introduced. Existing list inspect affordances, inline safe shortcuts, destructive action placement, and confirmation behavior must remain intact while truth semantics become stricter.
|
||||||
|
**Filament UI UX-001**: Existing Infolist and table layouts remain; this feature hardens top-level trust and caveat semantics rather than creating new screens.
|
||||||
|
**Badges**: Freshness, completeness, publication-readiness, and artifact-truth semantics must continue to use centralized badge domains and the existing governance truth partial.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and tested as an independent increment after the shared truth scaffolding is in place.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Test Scaffolding)
|
||||||
|
|
||||||
|
**Purpose**: Prepare reusable stale and partial evidence fixtures that all stories can build on.
|
||||||
|
|
||||||
|
- [X] T001 Extend shared stale and partial governance artifact fixture builders in `tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php`
|
||||||
|
- [X] T002 [P] Add or refine stale evidence seeding helpers used by evidence and review tests in `tests/Pest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Truth Propagation Prerequisite)
|
||||||
|
|
||||||
|
**Purpose**: Tighten the shared trust-propagation seam before any surface-specific story work begins.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T003 Refactor shared freshness-source and evidence-burden inputs used by evidence, review, and pack truth envelopes in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` without introducing a new resolver layer
|
||||||
|
- [X] T004 [P] Preserve source-derived stale semantics and required-section blocker behavior without adding a new freshness engine in `app/Services/Evidence/EvidenceCompletenessEvaluator.php` and `app/Services/TenantReviews/TenantReviewReadinessGate.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The central truth seam is ready for story-specific stale and publication hardening without introducing a second freshness framework.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Detect Stale Evidence Before Reusing It (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make stale evidence visible on snapshot, review, pack, and evidence-overview surfaces before operators reuse the artifact.
|
||||||
|
|
||||||
|
**Independent Test**: Seed fresh and stale evidence snapshots with the same structural completeness and verify that snapshot, linked review, linked pack, and evidence-overview rows downgrade only for the stale case.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T005 [P] [US1] Add fresh-versus-stale snapshot detail assertions in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
|
||||||
|
- [X] T006 [P] [US1] Add stale-evidence overview row assertions in `tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||||
|
- [X] T007 [P] [US1] Add stale-evidence trust assertions for linked reviews and packs in `tests/Feature/TenantReview/TenantReviewLifecycleTest.php` and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T008 [US1] Surface stale snapshot truth and next-step guidance on tenant evidence detail in `app/Filament/Resources/EvidenceSnapshotResource.php` and `app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||||
|
- [X] T009 [US1] Surface stale-evidence burden on tenant review detail without changing action topology in `app/Filament/Resources/TenantReviewResource.php` and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||||
|
- [X] T010 [US1] Surface stale-evidence burden on review pack list and detail before download in `app/Filament/Resources/ReviewPackResource.php` and `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
|
||||||
|
- [X] T011 [US1] Keep canonical evidence freshness rows aligned with snapshot detail in `app/Filament/Pages/Monitoring/EvidenceOverview.php` and `resources/views/filament/pages/monitoring/evidence-overview.blade.php`
|
||||||
|
- [X] T012 [US1] Run the focused stale-evidence verification pack in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Operators can now tell when evidence is stale before relying on snapshots, reviews, packs, or evidence-overview rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Distinguish Internal-Use Reviews From Publishable Reviews (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make partial or stale review evidence degrade review and pack publication posture into internal-only or cautionary states instead of looking publishable.
|
||||||
|
|
||||||
|
**Independent Test**: Render reviews and packs built from complete evidence, stale evidence, and partial evidence and verify that only the strongest case appears publishable.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T013 [P] [US2] Add partial-evidence and stale-publication-readiness assertions for tenant reviews in `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`
|
||||||
|
- [X] T014 [P] [US2] Add internal-only versus publishable pack assertions and download caveat coverage in `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T015 [US2] Tighten tenant review publication-readiness, next-step, and explanatory trust semantics for stale or partial evidence in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` and `app/Filament/Resources/TenantReviewResource.php`
|
||||||
|
- [X] T016 [US2] Tighten review pack publication-readiness and source-review caveat semantics for stale or partial evidence in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` and `app/Filament/Resources/ReviewPackResource.php`
|
||||||
|
- [X] T017 [US2] Keep the shared governance truth partial explicit about freshness versus publication readiness without adding page-local status language in `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php`
|
||||||
|
- [X] T018 [US2] Verify review and pack detail surfaces stay consistent about internal-only versus publishable trust in `tests/Feature/TenantReview/TenantReviewLifecycleTest.php` and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Review and pack surfaces now distinguish internal-use artifacts from publishable artifacts and no longer imply publication safety from mere downloadability or status alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Recover The Evidence Basis Across Review Surfaces (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Keep canonical review and evidence summary surfaces aligned with the tenant-scoped truth they summarize.
|
||||||
|
|
||||||
|
**Independent Test**: Seed tenants with mixed freshness and review readiness, then verify that the review register and evidence overview rows match the corresponding detail-page trust direction and preserve tenant-safe drill-through.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T019 [P] [US3] Add stale and partial canonical review-register alignment assertions in `tests/Feature/TenantReview/TenantReviewRegisterTest.php`
|
||||||
|
- [X] T020 [P] [US3] Add cross-surface drill-through, no-current-review next-step, and tenant-safe evidence-overview alignment assertions in `tests/Feature/Evidence/EvidenceOverviewPageTest.php` and `tests/Feature/TenantReview/TenantReviewRegisterTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T021 [US3] Align canonical review-register artifact truth, publication, and next-step rows with tenant review detail in `app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||||
|
- [X] T022 [US3] Preserve tenant-prefilter continuity, truthful drill-through, and the fresh-evidence-with-no-current-review next step between overview, register, and tenant detail surfaces in `app/Filament/Pages/Monitoring/EvidenceOverview.php` and `app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||||
|
- [X] T023 [US3] Keep tenant-scoped snapshot, review, and pack resources consistent with canonical summary language in `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/TenantReviewResource.php`, and `app/Filament/Resources/ReviewPackResource.php`
|
||||||
|
- [X] T024 [US3] Run the focused canonical-summary alignment verification pack in `tests/Feature/TenantReview/TenantReviewRegisterTest.php` and `tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Canonical summary surfaces now carry the same trust direction as the tenant-scoped detail pages they summarize.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Final consistency, authorization non-regression, formatting, and focused verification across all stories.
|
||||||
|
|
||||||
|
- [X] T025 [P] Review and align operator-facing truth copy across the shared presenter and truth partial in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` and `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php`
|
||||||
|
- [X] T026 [P] Add manage-action and tenant-entitlement non-regression coverage for touched evidence, review, and pack surfaces in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||||
|
- [X] T027 Run formatting for touched files using `vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- [X] T028 Run the final focused verification pack from `specs/174-evidence-freshness-publication-trust/quickstart.md`, including the SC-174-001 10-second manual scan validation, the shared list-surface review checklist in `docs/product/standards/list-surface-review-checklist.md`, the DB-only render and lightweight canonical-row-derivation guardrails in `specs/174-evidence-freshness-publication-trust/plan.md`, and the existing action run-launch semantics in `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/TenantReviewResource.php`, and `app/Filament/Resources/ReviewPackResource.php`, against `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, `tests/Feature/TenantReview/TenantReviewRegisterTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: Starts immediately and prepares shared stale and partial fixtures.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until the central truth propagation seam is tightened.
|
||||||
|
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the MVP stale-evidence visibility across key surfaces.
|
||||||
|
- **User Story 2 (Phase 4)**: Starts after Foundational; it can build on User Story 1 or proceed immediately after the presenter seam is stable, but should follow US1 for the cleanest rollout.
|
||||||
|
- **User Story 3 (Phase 5)**: Starts after Foundational and depends on tightened truth semantics from earlier phases to align canonical rows.
|
||||||
|
- **Polish (Phase 6)**: Starts after the desired user stories are complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Depends only on the shared truth propagation seam from Phase 2.
|
||||||
|
- **User Story 2 (P1)**: Depends on the shared truth propagation seam from Phase 2 and benefits from the stale-surface work in US1.
|
||||||
|
- **User Story 3 (P2)**: Depends on the shared truth propagation seam from Phase 2 and should consume the tightened truth semantics delivered by US1 and US2.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Shared fixture work must land before story-specific tests rely on new stale or partial scenarios.
|
||||||
|
- Tests should be updated before or alongside the related implementation tasks and must fail before the behavior change is considered complete.
|
||||||
|
- `ArtifactTruthPresenter` changes should land before downstream Filament resource and page copy cleanup for the same story.
|
||||||
|
- Focused story-level test runs should complete before moving to the next story.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T001` and `T002` can run in parallel once the shared stale and partial scenario matrix is agreed.
|
||||||
|
- `T005`, `T006`, and `T007` can run in parallel for User Story 1.
|
||||||
|
- `T013` and `T014` can run in parallel for User Story 2.
|
||||||
|
- `T019` and `T020` can run in parallel for User Story 3.
|
||||||
|
- `T025` and `T026` can run in parallel once implementation work is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Story 1 tests in parallel:
|
||||||
|
Task: T005 tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
|
||||||
|
Task: T006 tests/Feature/Evidence/EvidenceOverviewPageTest.php
|
||||||
|
Task: T007 tests/Feature/TenantReview/TenantReviewLifecycleTest.php and tests/Feature/ReviewPack/ReviewPackResourceTest.php
|
||||||
|
|
||||||
|
# Story 1 implementation split after truth propagation is stable:
|
||||||
|
Task: T008 app/Filament/Resources/EvidenceSnapshotResource.php and app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php
|
||||||
|
Task: T009 app/Filament/Resources/TenantReviewResource.php and app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php
|
||||||
|
Task: T010 app/Filament/Resources/ReviewPackResource.php and app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Story 2 tests in parallel:
|
||||||
|
Task: T013 tests/Feature/TenantReview/TenantReviewLifecycleTest.php
|
||||||
|
Task: T014 tests/Feature/ReviewPack/ReviewPackResourceTest.php
|
||||||
|
|
||||||
|
# Story 2 implementation split after failing assertions are in place:
|
||||||
|
Task: T015 app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php and app/Filament/Resources/TenantReviewResource.php
|
||||||
|
Task: T016 app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php and app/Filament/Resources/ReviewPackResource.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Story 3 tests in parallel:
|
||||||
|
Task: T019 tests/Feature/TenantReview/TenantReviewRegisterTest.php
|
||||||
|
Task: T020 tests/Feature/Evidence/EvidenceOverviewPageTest.php and tests/Feature/TenantReview/TenantReviewRegisterTest.php
|
||||||
|
|
||||||
|
# Story 3 implementation split after row-level truth expectations are clear:
|
||||||
|
Task: T021 app/Filament/Pages/Reviews/ReviewRegister.php
|
||||||
|
Task: T022 app/Filament/Pages/Monitoring/EvidenceOverview.php and app/Filament/Pages/Reviews/ReviewRegister.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 stale evidence is visible across snapshot, linked review, linked pack, and evidence overview surfaces.
|
||||||
|
5. Deploy or demo if the MVP confidence level is sufficient.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Complete Setup + Foundational so the central truth seam is stable.
|
||||||
|
2. Add User Story 1 and validate stale-evidence visibility.
|
||||||
|
3. Add User Story 2 and validate internal-only versus publishable trust.
|
||||||
|
4. Add User Story 3 and validate canonical summary alignment.
|
||||||
|
5. Finish with cross-cutting copy, authorization non-regression, formatting, and focused verification.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
With multiple developers:
|
||||||
|
|
||||||
|
1. Complete Setup + Foundational together.
|
||||||
|
2. Once Foundational is done:
|
||||||
|
- Developer A: User Story 1
|
||||||
|
- Developer B: User Story 2
|
||||||
|
- Developer C: User Story 3
|
||||||
|
3. Reconcile in Phase 6 with shared copy, authorization non-regression, formatting, and final focused tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks indicate different files and no dependency on incomplete predecessor tasks.
|
||||||
|
- The same touched file may appear in multiple stories because each story hardens a different user-visible outcome on the same existing surface.
|
||||||
|
- No task introduces a new persistence model, a new abstraction layer, or a new reporting subsystem.
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\TenantReviewCompletenessState;
|
||||||
|
use App\Support\TenantReviewStatus;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
|
uses(BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
|
|
||||||
|
pest()->browser()->timeout(15_000);
|
||||||
|
|
||||||
|
it('smokes tenant-scoped evidence freshness and publication trust surfaces', function (): void {
|
||||||
|
$staleTenant = Tenant::factory()->create(['name' => 'Browser Smoke Stale Tenant']);
|
||||||
|
[$user, $staleTenant] = createUserWithTenant(tenant: $staleTenant, role: 'owner');
|
||||||
|
|
||||||
|
$partialTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $staleTenant->workspace_id,
|
||||||
|
'name' => 'Browser Smoke Partial Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $partialTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$staleSnapshot = seedStaleTenantReviewEvidence($staleTenant);
|
||||||
|
$partialSnapshot = seedPartialTenantReviewEvidence($partialTenant);
|
||||||
|
|
||||||
|
$partialReview = $this->makeArtifactTruthReview(
|
||||||
|
tenant: $partialTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $partialSnapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Ready->value,
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$staleReview = $this->makeArtifactTruthReview(
|
||||||
|
tenant: $staleTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $staleSnapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Published->value,
|
||||||
|
'published_at' => now(),
|
||||||
|
'published_by_user_id' => (int) $user->getKey(),
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$stalePack = $this->makeArtifactTruthReviewPack(
|
||||||
|
tenant: $staleTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $staleSnapshot,
|
||||||
|
review: $staleReview,
|
||||||
|
summaryOverrides: [
|
||||||
|
'review_status' => TenantReviewStatus::Published->value,
|
||||||
|
'review_completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $staleTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $staleTenant->workspace_id);
|
||||||
|
|
||||||
|
visit(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant))
|
||||||
|
->waitForText('Artifact truth')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Stale')
|
||||||
|
->assertSee('Refresh the stale evidence before relying on this snapshot');
|
||||||
|
|
||||||
|
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $partialReview], $partialTenant))
|
||||||
|
->waitForText('Artifact truth')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertSee('Complete the evidence basis before publishing this review');
|
||||||
|
|
||||||
|
visit(ReviewPackResource::getUrl('index', tenant: $staleTenant))
|
||||||
|
->waitForText('Artifact truth')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertSee('Refresh the source review before sharing this pack')
|
||||||
|
->assertSee('Download');
|
||||||
|
|
||||||
|
visit(ReviewPackResource::getUrl('view', ['record' => $stalePack], tenant: $staleTenant))
|
||||||
|
->waitForText('Artifact truth')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertSee('Refresh the source review before sharing this pack')
|
||||||
|
->assertSee('Download');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smokes canonical evidence and review trust surfaces', function (): void {
|
||||||
|
$staleTenant = Tenant::factory()->create(['name' => 'Browser Canonical Stale Tenant']);
|
||||||
|
[$user, $staleTenant] = createUserWithTenant(tenant: $staleTenant, role: 'owner');
|
||||||
|
|
||||||
|
$partialTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $staleTenant->workspace_id,
|
||||||
|
'name' => 'Browser Canonical Partial Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $partialTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$freshTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $staleTenant->workspace_id,
|
||||||
|
'name' => 'Browser Canonical Fresh Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $freshTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$staleSnapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($staleTenant);
|
||||||
|
$freshSnapshot = $this->makeArtifactTruthEvidenceSnapshot($freshTenant);
|
||||||
|
$partialSnapshot = seedPartialTenantReviewEvidence($partialTenant);
|
||||||
|
|
||||||
|
$this->makeArtifactTruthReview(
|
||||||
|
tenant: $staleTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $staleSnapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Published->value,
|
||||||
|
'published_at' => now(),
|
||||||
|
'published_by_user_id' => (int) $user->getKey(),
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeArtifactTruthReview(
|
||||||
|
tenant: $partialTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $partialSnapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Ready->value,
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $staleTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $staleTenant->workspace_id);
|
||||||
|
|
||||||
|
visit(route('admin.evidence.overview'))
|
||||||
|
->waitForText('Browser Canonical Stale Tenant')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Browser Canonical Fresh Tenant')
|
||||||
|
->assertSee('Refresh the stale evidence before relying on this snapshot')
|
||||||
|
->assertSee('Create a current review from this evidence snapshot');
|
||||||
|
|
||||||
|
visit('/admin/reviews')
|
||||||
|
->waitForText('Browser Canonical Stale Tenant')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Browser Canonical Partial Tenant')
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertSee('Refresh the evidence basis before publishing this review')
|
||||||
|
->assertSee('Complete the evidence basis before publishing this review')
|
||||||
|
->assertDontSee('Publishable');
|
||||||
|
});
|
||||||
@ -45,6 +45,40 @@ protected function makeArtifactTruthEvidenceSnapshot(
|
|||||||
return EvidenceSnapshot::query()->create(array_replace($defaults, $snapshotOverrides));
|
return EvidenceSnapshot::query()->create(array_replace($defaults, $snapshotOverrides));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function makeStaleArtifactTruthEvidenceSnapshot(
|
||||||
|
Tenant $tenant,
|
||||||
|
array $snapshotOverrides = [],
|
||||||
|
array $summaryOverrides = [],
|
||||||
|
): EvidenceSnapshot {
|
||||||
|
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, $snapshotOverrides, $summaryOverrides);
|
||||||
|
|
||||||
|
return $this->restateArtifactTruthEvidenceSnapshot(
|
||||||
|
$snapshot,
|
||||||
|
EvidenceCompletenessState::Stale,
|
||||||
|
array_replace([
|
||||||
|
'missing_dimensions' => 0,
|
||||||
|
'stale_dimensions' => 2,
|
||||||
|
], $summaryOverrides),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function makePartialArtifactTruthEvidenceSnapshot(
|
||||||
|
Tenant $tenant,
|
||||||
|
array $snapshotOverrides = [],
|
||||||
|
array $summaryOverrides = [],
|
||||||
|
): EvidenceSnapshot {
|
||||||
|
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, $snapshotOverrides, $summaryOverrides);
|
||||||
|
|
||||||
|
return $this->restateArtifactTruthEvidenceSnapshot(
|
||||||
|
$snapshot,
|
||||||
|
EvidenceCompletenessState::Partial,
|
||||||
|
array_replace([
|
||||||
|
'missing_dimensions' => 1,
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
], $summaryOverrides),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
protected function makeArtifactTruthReview(
|
protected function makeArtifactTruthReview(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
User $user,
|
User $user,
|
||||||
@ -146,4 +180,47 @@ protected function makeArtifactTruthRun(
|
|||||||
|
|
||||||
return OperationRun::factory()->create(array_replace($defaults, $attributes));
|
return OperationRun::factory()->create(array_replace($defaults, $attributes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $summaryOverrides
|
||||||
|
*/
|
||||||
|
private function restateArtifactTruthEvidenceSnapshot(
|
||||||
|
EvidenceSnapshot $snapshot,
|
||||||
|
EvidenceCompletenessState $completenessState,
|
||||||
|
array $summaryOverrides = [],
|
||||||
|
): EvidenceSnapshot {
|
||||||
|
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
||||||
|
|
||||||
|
$summary = array_replace($summary, match ($completenessState) {
|
||||||
|
EvidenceCompletenessState::Stale => [
|
||||||
|
'dimension_count' => max(1, (int) ($summary['dimension_count'] ?? 5)),
|
||||||
|
'missing_dimensions' => 0,
|
||||||
|
'stale_dimensions' => max(1, (int) ($summary['stale_dimensions'] ?? 1)),
|
||||||
|
],
|
||||||
|
EvidenceCompletenessState::Partial => [
|
||||||
|
'dimension_count' => max(1, (int) ($summary['dimension_count'] ?? 5)),
|
||||||
|
'missing_dimensions' => max(1, (int) ($summary['missing_dimensions'] ?? 1)),
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
],
|
||||||
|
EvidenceCompletenessState::Missing => [
|
||||||
|
'dimension_count' => (int) ($summary['dimension_count'] ?? 0),
|
||||||
|
'missing_dimensions' => max(1, (int) ($summary['missing_dimensions'] ?? 1)),
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
],
|
||||||
|
EvidenceCompletenessState::Complete => [
|
||||||
|
'dimension_count' => max(1, (int) ($summary['dimension_count'] ?? 5)),
|
||||||
|
'missing_dimensions' => 0,
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
$summary = array_replace_recursive($summary, $summaryOverrides);
|
||||||
|
|
||||||
|
$snapshot->forceFill([
|
||||||
|
'completeness_state' => $completenessState->value,
|
||||||
|
'summary' => $summary,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $snapshot->fresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,8 +10,9 @@
|
|||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
|
|
||||||
it('shows only authorized tenant rows on the workspace evidence overview', function (): void {
|
it('shows only authorized tenant rows on the workspace evidence overview', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
@ -91,3 +92,30 @@
|
|||||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantB->getKey()]], tenant: $tenantB), false)
|
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantB->getKey()]], tenant: $tenantB), false)
|
||||||
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantA->getKey()]], tenant: $tenantA), false);
|
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantA->getKey()]], tenant: $tenantA), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows stale evidence burden and a create-review next step on the overview', function (): void {
|
||||||
|
$staleTenant = Tenant::factory()->create(['name' => 'Stale Tenant']);
|
||||||
|
[$user, $staleTenant] = createUserWithTenant(tenant: $staleTenant, role: 'owner');
|
||||||
|
|
||||||
|
$freshTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $staleTenant->workspace_id,
|
||||||
|
'name' => 'Fresh Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $freshTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$staleSnapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($staleTenant);
|
||||||
|
$freshSnapshot = $this->makeArtifactTruthEvidenceSnapshot($freshTenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $staleTenant->workspace_id])
|
||||||
|
->get(route('admin.evidence.overview'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee($staleTenant->name)
|
||||||
|
->assertSee($freshTenant->name)
|
||||||
|
->assertSee('Refresh the stale evidence before relying on this snapshot')
|
||||||
|
->assertSee('Create a current review from this evidence snapshot')
|
||||||
|
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant), false)
|
||||||
|
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $freshSnapshot], tenant: $freshTenant), false);
|
||||||
|
});
|
||||||
|
|||||||
@ -21,8 +21,9 @@
|
|||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
|
|
||||||
function seedEvidenceDomain(Tenant $tenant): void
|
function seedEvidenceDomain(Tenant $tenant): void
|
||||||
{
|
{
|
||||||
@ -172,6 +173,30 @@ function seedEvidenceDomain(Tenant $tenant): void
|
|||||||
->assertSee('Refresh evidence before using this snapshot');
|
->assertSee('Refresh evidence before using this snapshot');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows stale evidence snapshots as cautionary while fresh snapshots remain current', function (): void {
|
||||||
|
$freshTenant = Tenant::factory()->create();
|
||||||
|
[$user, $freshTenant] = createUserWithTenant(tenant: $freshTenant, role: 'owner');
|
||||||
|
$staleTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $freshTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $staleTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$freshSnapshot = $this->makeArtifactTruthEvidenceSnapshot($freshTenant);
|
||||||
|
$staleSnapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($staleTenant);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $freshSnapshot], tenant: $freshTenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('No action needed')
|
||||||
|
->assertDontSee('Refresh the stale evidence before relying on this snapshot');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Stale')
|
||||||
|
->assertSee('Refresh the stale evidence before relying on this snapshot');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders readable evidence dimension summaries and keeps raw json available', function (): void {
|
it('renders readable evidence dimension summaries and keeps raw json available', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|||||||
169
tests/Feature/Filament/DashboardKpisWidgetTest.php
Normal file
169
tests/Feature/Filament/DashboardKpisWidgetTest.php
Normal 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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
210
tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
Normal file
210
tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
Normal 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');
|
||||||
|
});
|
||||||
@ -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]);
|
||||||
|
});
|
||||||
|
|||||||
157
tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
Normal file
157
tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
Normal 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',
|
||||||
|
]));
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -21,8 +21,9 @@
|
|||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
|
|
||||||
beforeEach(function (): void {
|
beforeEach(function (): void {
|
||||||
Storage::fake('exports');
|
Storage::fake('exports');
|
||||||
@ -366,6 +367,103 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
->assertSee('Open the source review before sharing this pack');
|
->assertSee('Open the source review before sharing this pack');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows internal-only caveats for packs generated from stale source evidence before download', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$snapshot = seedStaleTenantReviewEvidence($tenant);
|
||||||
|
$review = $this->makeArtifactTruthReview(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $snapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now(),
|
||||||
|
'published_by_user_id' => (int) $user->getKey(),
|
||||||
|
'completeness_state' => 'complete',
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$pack = $this->makeArtifactTruthReviewPack(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $snapshot,
|
||||||
|
review: $review,
|
||||||
|
summaryOverrides: [
|
||||||
|
'review_status' => 'published',
|
||||||
|
'review_completeness_state' => 'complete',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListReviewPacks::class)
|
||||||
|
->assertTableActionVisible('download', $pack)
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertSee('Refresh the source review before sharing this pack');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertSee('Download')
|
||||||
|
->assertSee('Refresh the source review before sharing this pack');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps packs from partial evidence internal only instead of publishable', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$snapshot = seedPartialTenantReviewEvidence($tenant);
|
||||||
|
$review = $this->makeArtifactTruthReview(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $snapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => 'ready',
|
||||||
|
'completeness_state' => 'complete',
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$pack = $this->makeArtifactTruthReviewPack(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $snapshot,
|
||||||
|
review: $review,
|
||||||
|
summaryOverrides: [
|
||||||
|
'review_status' => 'ready',
|
||||||
|
'review_completeness_state' => 'complete',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertSee('Complete the source review before sharing this pack')
|
||||||
|
->assertDontSee('Publishable');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows download header action on view page for a ready pack', function (): void {
|
it('shows download header action on view page for a ready pack', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|||||||
@ -60,8 +60,6 @@
|
|||||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Artifact truth')
|
->assertSee('Artifact truth')
|
||||||
->assertSee('Publishable')
|
|
||||||
->assertSee('No action needed')
|
|
||||||
->assertSee('#'.$review->getKey())
|
->assertSee('#'.$review->getKey())
|
||||||
->assertSee('Review status');
|
->assertSee('Review status');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,8 +4,12 @@
|
|||||||
|
|
||||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||||
use App\Services\TenantReviews\TenantReviewReadinessGate;
|
use App\Services\TenantReviews\TenantReviewReadinessGate;
|
||||||
|
use App\Support\TenantReviewCompletenessState;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
|
uses(BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
|
|
||||||
it('blocks publication when required review sections are missing from the anchored evidence basis', function (): void {
|
it('blocks publication when required review sections are missing from the anchored evidence basis', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
@ -35,7 +39,24 @@
|
|||||||
|
|
||||||
it('publishes ready tenant reviews and archives them without mutating the published evidence history', function (): void {
|
it('publishes ready tenant reviews and archives them without mutating the published evidence history', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$review = composeTenantReviewForTest($tenant, $user);
|
$review = $this->makeArtifactTruthReview(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $this->makeArtifactTruthEvidenceSnapshot($tenant),
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Ready->value,
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
$published = app(TenantReviewLifecycleService::class)->publish($review, $user);
|
$published = app(TenantReviewLifecycleService::class)->publish($review, $user);
|
||||||
$publishedAt = $published->published_at?->toIso8601String();
|
$publishedAt = $published->published_at?->toIso8601String();
|
||||||
@ -58,3 +79,68 @@
|
|||||||
->and($archivedTruth->publicationReadiness)->toBe('internal_only')
|
->and($archivedTruth->publicationReadiness)->toBe('internal_only')
|
||||||
->and($archivedTruth->nextStepText())->toBe('No action needed');
|
->and($archivedTruth->nextStepText())->toBe('No action needed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps stale published reviews internal only even when the lifecycle status is published', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$snapshot = seedStaleTenantReviewEvidence($tenant);
|
||||||
|
|
||||||
|
$review = $this->makeArtifactTruthReview(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $snapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Published->value,
|
||||||
|
'published_at' => now(),
|
||||||
|
'published_by_user_id' => (int) $user->getKey(),
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$truth = app(ArtifactTruthPresenter::class)->forTenantReview($review);
|
||||||
|
|
||||||
|
expect($truth->freshnessState)->toBe('stale')
|
||||||
|
->and($truth->publicationReadiness)->toBe('internal_only')
|
||||||
|
->and($truth->primaryLabel)->toBe('Internal only')
|
||||||
|
->and($truth->nextStepText())->toBe('Refresh the evidence basis before publishing this review');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downgrades structurally ready reviews with partial evidence to internal only', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$snapshot = seedPartialTenantReviewEvidence($tenant);
|
||||||
|
|
||||||
|
$review = $this->makeArtifactTruthReview(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $snapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Ready->value,
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$truth = app(ArtifactTruthPresenter::class)->forTenantReview($review);
|
||||||
|
|
||||||
|
expect($truth->contentState)->toBe('partial')
|
||||||
|
->and($truth->freshnessState)->toBe('current')
|
||||||
|
->and($truth->publicationReadiness)->toBe('internal_only')
|
||||||
|
->and($truth->primaryLabel)->toBe('Internal only')
|
||||||
|
->and($truth->nextStepText())->toBe('Complete the evidence basis before publishing this review');
|
||||||
|
});
|
||||||
|
|||||||
@ -5,8 +5,13 @@
|
|||||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\TenantReviewCompletenessState;
|
||||||
|
use App\Support\TenantReviewStatus;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
|
uses(BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
|
|
||||||
it('lists only entitled tenant reviews in the canonical review register and filters by tenant', function (): void {
|
it('lists only entitled tenant reviews in the canonical review register and filters by tenant', function (): void {
|
||||||
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
|
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
|
||||||
@ -59,3 +64,66 @@
|
|||||||
->assertSee('No review records match this view')
|
->assertSee('No review records match this view')
|
||||||
->assertSee('Clear filters');
|
->assertSee('Clear filters');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps stale and partial review rows aligned with tenant review detail trust', function (): void {
|
||||||
|
$staleTenant = Tenant::factory()->create(['name' => 'Stale Tenant']);
|
||||||
|
[$user, $staleTenant] = createUserWithTenant(tenant: $staleTenant, role: 'owner');
|
||||||
|
|
||||||
|
$partialTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $staleTenant->workspace_id,
|
||||||
|
'name' => 'Partial Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $partialTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$staleReview = $this->makeArtifactTruthReview(
|
||||||
|
tenant: $staleTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: seedStaleTenantReviewEvidence($staleTenant),
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Published->value,
|
||||||
|
'published_at' => now(),
|
||||||
|
'published_by_user_id' => (int) $user->getKey(),
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$partialReview = $this->makeArtifactTruthReview(
|
||||||
|
tenant: $partialTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: seedPartialTenantReviewEvidence($partialTenant),
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Ready->value,
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $staleTenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ReviewRegister::class)
|
||||||
|
->assertCanSeeTableRecords([$staleReview, $partialReview])
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertSee('Refresh the evidence basis before publishing this review')
|
||||||
|
->assertSee('Complete the evidence basis before publishing this review')
|
||||||
|
->assertDontSee('Publishable');
|
||||||
|
});
|
||||||
|
|||||||
122
tests/Pest.php
122
tests/Pest.php
@ -16,6 +16,7 @@
|
|||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
use App\Services\TenantReviews\TenantReviewService;
|
||||||
use App\Services\Tenants\TenantActionPolicySurface;
|
use App\Services\Tenants\TenantActionPolicySurface;
|
||||||
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
@ -569,6 +570,70 @@ function seedTenantReviewEvidence(
|
|||||||
return $snapshot->load('items');
|
return $snapshot->load('items');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $permissionPayload
|
||||||
|
* @param array<string, mixed> $rolePayload
|
||||||
|
*/
|
||||||
|
function seedStaleTenantReviewEvidence(
|
||||||
|
Tenant $tenant,
|
||||||
|
array $permissionPayload = [],
|
||||||
|
array $rolePayload = [],
|
||||||
|
int $findingCount = 3,
|
||||||
|
int $driftCount = 1,
|
||||||
|
int $operationRunCount = 1,
|
||||||
|
array $summaryOverrides = [],
|
||||||
|
): EvidenceSnapshot {
|
||||||
|
$snapshot = seedTenantReviewEvidence(
|
||||||
|
tenant: $tenant,
|
||||||
|
permissionPayload: $permissionPayload,
|
||||||
|
rolePayload: $rolePayload,
|
||||||
|
findingCount: $findingCount,
|
||||||
|
driftCount: $driftCount,
|
||||||
|
operationRunCount: $operationRunCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
return restateTenantReviewEvidenceSnapshot(
|
||||||
|
$snapshot,
|
||||||
|
EvidenceCompletenessState::Stale,
|
||||||
|
array_replace([
|
||||||
|
'missing_dimensions' => 0,
|
||||||
|
'stale_dimensions' => 2,
|
||||||
|
], $summaryOverrides),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $permissionPayload
|
||||||
|
* @param array<string, mixed> $rolePayload
|
||||||
|
*/
|
||||||
|
function seedPartialTenantReviewEvidence(
|
||||||
|
Tenant $tenant,
|
||||||
|
array $permissionPayload = [],
|
||||||
|
array $rolePayload = [],
|
||||||
|
int $findingCount = 3,
|
||||||
|
int $driftCount = 1,
|
||||||
|
int $operationRunCount = 1,
|
||||||
|
array $summaryOverrides = [],
|
||||||
|
): EvidenceSnapshot {
|
||||||
|
$snapshot = seedTenantReviewEvidence(
|
||||||
|
tenant: $tenant,
|
||||||
|
permissionPayload: $permissionPayload,
|
||||||
|
rolePayload: $rolePayload,
|
||||||
|
findingCount: $findingCount,
|
||||||
|
driftCount: $driftCount,
|
||||||
|
operationRunCount: $operationRunCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
return restateTenantReviewEvidenceSnapshot(
|
||||||
|
$snapshot,
|
||||||
|
EvidenceCompletenessState::Partial,
|
||||||
|
array_replace([
|
||||||
|
'missing_dimensions' => 1,
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
], $summaryOverrides),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function composeTenantReviewForTest(Tenant $tenant, User $user, ?EvidenceSnapshot $snapshot = null): TenantReview
|
function composeTenantReviewForTest(Tenant $tenant, User $user, ?EvidenceSnapshot $snapshot = null): TenantReview
|
||||||
{
|
{
|
||||||
$snapshot ??= seedTenantReviewEvidence($tenant);
|
$snapshot ??= seedTenantReviewEvidence($tenant);
|
||||||
@ -586,6 +651,63 @@ function composeTenantReviewForTest(Tenant $tenant, User $user, ?EvidenceSnapsho
|
|||||||
return $review->refresh();
|
return $review->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $summaryOverrides
|
||||||
|
*/
|
||||||
|
function restateTenantReviewEvidenceSnapshot(
|
||||||
|
EvidenceSnapshot $snapshot,
|
||||||
|
EvidenceCompletenessState $completenessState,
|
||||||
|
array $summaryOverrides = [],
|
||||||
|
): EvidenceSnapshot {
|
||||||
|
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
||||||
|
|
||||||
|
$summary = array_replace($summary, match ($completenessState) {
|
||||||
|
EvidenceCompletenessState::Stale => [
|
||||||
|
'dimension_count' => max(1, (int) ($summary['dimension_count'] ?? 5)),
|
||||||
|
'missing_dimensions' => 0,
|
||||||
|
'stale_dimensions' => max(1, (int) ($summary['stale_dimensions'] ?? 1)),
|
||||||
|
],
|
||||||
|
EvidenceCompletenessState::Partial => [
|
||||||
|
'dimension_count' => max(1, (int) ($summary['dimension_count'] ?? 5)),
|
||||||
|
'missing_dimensions' => max(1, (int) ($summary['missing_dimensions'] ?? 1)),
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
],
|
||||||
|
EvidenceCompletenessState::Missing => [
|
||||||
|
'dimension_count' => (int) ($summary['dimension_count'] ?? 0),
|
||||||
|
'missing_dimensions' => max(1, (int) ($summary['missing_dimensions'] ?? 1)),
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
],
|
||||||
|
EvidenceCompletenessState::Complete => [
|
||||||
|
'dimension_count' => max(1, (int) ($summary['dimension_count'] ?? 5)),
|
||||||
|
'missing_dimensions' => 0,
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
$summary = array_replace_recursive($summary, $summaryOverrides);
|
||||||
|
|
||||||
|
$snapshot->forceFill([
|
||||||
|
'completeness_state' => $completenessState->value,
|
||||||
|
'summary' => $summary,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$primaryItem = $snapshot->items()->where('required', true)->orderBy('sort_order')->first();
|
||||||
|
|
||||||
|
if ($primaryItem !== null) {
|
||||||
|
$primaryItem->forceFill([
|
||||||
|
'state' => $completenessState->value,
|
||||||
|
'measured_at' => $completenessState === EvidenceCompletenessState::Stale
|
||||||
|
? now()->subDays(45)
|
||||||
|
: ($primaryItem->measured_at ?? now()),
|
||||||
|
'freshness_at' => $completenessState === EvidenceCompletenessState::Stale
|
||||||
|
? now()->subDays(45)
|
||||||
|
: ($primaryItem->freshness_at ?? now()),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snapshot->fresh('items');
|
||||||
|
}
|
||||||
|
|
||||||
function setTenantPanelContext(Tenant $tenant): void
|
function setTenantPanelContext(Tenant $tenant): void
|
||||||
{
|
{
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user