feat: harden baseline compare summary trust surfaces #196
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -108,6 +108,8 @@ ## Active Technologies
|
|||||||
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
|
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
|
||||||
- PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer` (164-run-detail-hardening)
|
- PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer` (164-run-detail-hardening)
|
||||||
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
|
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks` (165-baseline-summary-trust)
|
||||||
|
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -127,8 +129,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 165-baseline-summary-trust: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
|
||||||
- 164-run-detail-hardening: Added PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
|
- 164-run-detail-hardening: Added PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
|
||||||
- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||||
- 163-baseline-subject-resolution: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -104,6 +104,9 @@ class BaselineCompareLanding extends Page
|
|||||||
/** @var array<string, mixed>|null */
|
/** @var array<string, mixed>|null */
|
||||||
public ?array $operatorExplanation = null;
|
public ?array $operatorExplanation = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $summaryAssessment = null;
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -166,6 +169,7 @@ public function refreshStats(): void
|
|||||||
: null;
|
: null;
|
||||||
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||||
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
||||||
|
$this->summaryAssessment = $stats->summaryAssessment()->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -248,6 +252,7 @@ protected function getViewData(): array
|
|||||||
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
||||||
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
||||||
'whyNoFindingsColor' => $whyNoFindingsColor,
|
'whyNoFindingsColor' => $whyNoFindingsColor,
|
||||||
|
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,11 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
@ -22,38 +25,47 @@ protected function getViewData(): array
|
|||||||
|
|
||||||
$empty = [
|
$empty = [
|
||||||
'hasAssignment' => false,
|
'hasAssignment' => false,
|
||||||
'state' => 'no_assignment',
|
|
||||||
'message' => null,
|
|
||||||
'profileName' => null,
|
'profileName' => null,
|
||||||
'findingsCount' => 0,
|
|
||||||
'highCount' => 0,
|
|
||||||
'mediumCount' => 0,
|
|
||||||
'lowCount' => 0,
|
|
||||||
'lastComparedAt' => null,
|
'lastComparedAt' => null,
|
||||||
'landingUrl' => null,
|
'landingUrl' => null,
|
||||||
|
'runUrl' => null,
|
||||||
|
'findingsUrl' => null,
|
||||||
|
'nextActionUrl' => null,
|
||||||
|
'summaryAssessment' => null,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forWidget($tenant);
|
$stats = BaselineCompareStats::forTenant($tenant);
|
||||||
|
|
||||||
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
|
||||||
|
$runUrl = $stats->operationRunId !== null
|
||||||
|
? OperationRunLinks::view($stats->operationRunId, $tenant)
|
||||||
|
: null;
|
||||||
|
$findingsUrl = FindingResource::getUrl('index', tenant: $tenant);
|
||||||
|
$summaryAssessment = $stats->summaryAssessment();
|
||||||
|
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
||||||
|
'run' => $runUrl,
|
||||||
|
'findings' => $findingsUrl,
|
||||||
|
'landing' => $landingUrl,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'hasAssignment' => true,
|
'hasAssignment' => true,
|
||||||
'state' => $stats->state,
|
|
||||||
'message' => $stats->message,
|
|
||||||
'profileName' => $stats->profileName,
|
'profileName' => $stats->profileName,
|
||||||
'findingsCount' => $stats->findingsCount ?? 0,
|
|
||||||
'highCount' => $stats->severityCounts['high'] ?? 0,
|
|
||||||
'mediumCount' => $stats->severityCounts['medium'] ?? 0,
|
|
||||||
'lowCount' => $stats->severityCounts['low'] ?? 0,
|
|
||||||
'lastComparedAt' => $stats->lastComparedHuman,
|
'lastComparedAt' => $stats->lastComparedHuman,
|
||||||
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
|
'landingUrl' => $landingUrl,
|
||||||
|
'runUrl' => $runUrl,
|
||||||
|
'findingsUrl' => $findingsUrl,
|
||||||
|
'nextActionUrl' => $nextActionUrl,
|
||||||
|
'summaryAssessment' => $summaryAssessment->toArray(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
@ -34,6 +31,8 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
$compareStats = BaselineCompareStats::forTenant($tenant);
|
||||||
|
$compareAssessment = $compareStats->summaryAssessment();
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|
||||||
@ -48,71 +47,30 @@ protected function getViewData(): array
|
|||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'High severity drift findings',
|
'title' => 'High severity drift findings',
|
||||||
'body' => "{$highSeverityCount} finding(s) need review.",
|
'body' => "{$highSeverityCount} finding(s) need review.",
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
'badge' => 'Drift',
|
||||||
'badgeColor' => 'danger',
|
'badgeColor' => 'danger',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$latestBaselineCompareSuccess = OperationRun::query()
|
if ($compareAssessment->stateFamily !== 'positive') {
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'succeeded')
|
|
||||||
->whereNotNull('completed_at')
|
|
||||||
->latest('completed_at')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $latestBaselineCompareSuccess) {
|
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'No baseline compare yet',
|
'title' => 'Baseline compare posture',
|
||||||
'body' => 'Run a baseline compare after your tenant has an assigned baseline snapshot.',
|
'body' => $compareAssessment->headline,
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
'supportingMessage' => $compareAssessment->supportingMessage,
|
||||||
'badge' => 'Drift',
|
'badge' => 'Baseline',
|
||||||
'badgeColor' => 'warning',
|
'badgeColor' => $compareAssessment->tone,
|
||||||
];
|
'nextStep' => $compareAssessment->nextActionLabel(),
|
||||||
} else {
|
|
||||||
$isStale = $latestBaselineCompareSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
|
||||||
|
|
||||||
if ($isStale) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Baseline compare stale',
|
|
||||||
'body' => 'Last baseline compare is older than 7 days.',
|
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestBaselineCompareFailure = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'failed')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($latestBaselineCompareFailure instanceof OperationRun) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Baseline compare failed',
|
|
||||||
'body' => 'Investigate the latest failed run.',
|
|
||||||
'url' => OperationRunLinks::view($latestBaselineCompareFailure, $tenant),
|
|
||||||
'badge' => 'Operations',
|
|
||||||
'badgeColor' => 'danger',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeRuns = (int) OperationRun::query()
|
$activeRuns = ActiveRuns::existForTenant($tenant)
|
||||||
->where('tenant_id', $tenantId)
|
? (int) \App\Models\OperationRun::query()->where('tenant_id', $tenantId)->active()->count()
|
||||||
->active()
|
: 0;
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($activeRuns > 0) {
|
if ($activeRuns > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'Operations in progress',
|
'title' => 'Operations in progress',
|
||||||
'body' => "{$activeRuns} run(s) are active.",
|
'body' => "{$activeRuns} run(s) are active.",
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'badge' => 'Operations',
|
'badge' => 'Operations',
|
||||||
'badgeColor' => 'warning',
|
'badgeColor' => 'warning',
|
||||||
];
|
];
|
||||||
@ -125,24 +83,16 @@ protected function getViewData(): array
|
|||||||
if ($items === []) {
|
if ($items === []) {
|
||||||
$healthyChecks = [
|
$healthyChecks = [
|
||||||
[
|
[
|
||||||
'title' => 'Drift findings look healthy',
|
'title' => 'Baseline compare looks trustworthy',
|
||||||
'body' => 'No high severity drift findings are open.',
|
'body' => $compareAssessment->headline,
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'linkLabel' => 'View findings',
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'Baseline compares are up to date',
|
'title' => 'No high severity drift is open',
|
||||||
'body' => $latestBaselineCompareSuccess?->completed_at
|
'body' => 'No high severity drift findings are currently open for this tenant.',
|
||||||
? 'Last baseline compare: '.$latestBaselineCompareSuccess->completed_at->diffForHumans(['short' => true]).'.'
|
|
||||||
: 'Baseline compare history is available in Baseline Compare.',
|
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
|
||||||
'linkLabel' => 'Open Baseline Compare',
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'No active operations',
|
'title' => 'No active operations',
|
||||||
'body' => 'Nothing is currently running for this tenant.',
|
'body' => 'Nothing is currently running for this tenant.',
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'linkLabel' => 'View operations',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Tenant;
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -30,28 +31,29 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forTenant($tenant);
|
$stats = BaselineCompareStats::forTenant($tenant);
|
||||||
|
$summaryAssessment = $stats->summaryAssessment();
|
||||||
$uncoveredTypes = $stats->uncoveredTypes ?? [];
|
|
||||||
$uncoveredTypes = is_array($uncoveredTypes) ? $uncoveredTypes : [];
|
|
||||||
|
|
||||||
$coverageStatus = $stats->coverageStatus;
|
|
||||||
$hasWarnings = in_array($coverageStatus, ['warning', 'unproven'], true) && $uncoveredTypes !== [];
|
|
||||||
|
|
||||||
$runUrl = null;
|
$runUrl = null;
|
||||||
|
|
||||||
if ($stats->operationRunId !== null) {
|
if ($stats->operationRunId !== null) {
|
||||||
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
|
||||||
|
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
||||||
|
'run' => $runUrl,
|
||||||
|
'landing' => $landingUrl,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
$shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
||||||
|
|| ($summaryAssessment->stateFamily === 'action_required' && $summaryAssessment->evaluationResult === 'failed_result');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
|
'shouldShow' => $shouldShow,
|
||||||
|
'landingUrl' => $landingUrl,
|
||||||
'runUrl' => $runUrl,
|
'runUrl' => $runUrl,
|
||||||
|
'nextActionUrl' => $nextActionUrl,
|
||||||
|
'summaryAssessment' => $summaryAssessment->toArray(),
|
||||||
'state' => $stats->state,
|
'state' => $stats->state,
|
||||||
'message' => $stats->message,
|
|
||||||
'coverageStatus' => $coverageStatus,
|
|
||||||
'fidelity' => $stats->fidelity,
|
|
||||||
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
|
||||||
'uncoveredTypes' => $uncoveredTypes,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
'full_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check-badge'),
|
'full_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check-badge'),
|
||||||
'incomplete_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-exclamation-triangle'),
|
'incomplete_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
'suppressed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-eye-slash'),
|
'suppressed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-eye-slash'),
|
||||||
|
'failed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-x-circle'),
|
||||||
'no_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check'),
|
'no_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check'),
|
||||||
'unavailable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-no-symbol'),
|
'unavailable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-no-symbol'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
|
|||||||
@ -248,6 +248,15 @@ final class OperatorOutcomeTaxonomy
|
|||||||
'legacy_aliases' => ['Suppressed'],
|
'legacy_aliases' => ['Suppressed'],
|
||||||
'notes' => 'Normal output was intentionally suppressed because the system could not safely present it as final.',
|
'notes' => 'Normal output was intentionally suppressed because the system could not safely present it as final.',
|
||||||
],
|
],
|
||||||
|
'failed_result' => [
|
||||||
|
'axis' => 'execution_outcome',
|
||||||
|
'label' => 'Failed result',
|
||||||
|
'color' => 'danger',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Execution failed'],
|
||||||
|
'notes' => 'The workflow ended without producing a usable result and needs operator investigation.',
|
||||||
|
],
|
||||||
'no_result' => [
|
'no_result' => [
|
||||||
'axis' => 'execution_outcome',
|
'axis' => 'execution_outcome',
|
||||||
'label' => 'No issues detected',
|
'label' => 'No issues detected',
|
||||||
|
|||||||
@ -23,6 +23,8 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
$reason = $stats->reasonCode !== null
|
$reason = $stats->reasonCode !== null
|
||||||
? $this->reasonPresenter->forArtifactTruth($stats->reasonCode, 'baseline_compare')
|
? $this->reasonPresenter->forArtifactTruth($stats->reasonCode, 'baseline_compare')
|
||||||
: null;
|
: null;
|
||||||
|
$isFailed = $stats->state === 'failed';
|
||||||
|
$isInProgress = $stats->state === 'comparing';
|
||||||
$hasCoverageWarnings = in_array($stats->coverageStatus, ['warning', 'unproven'], true);
|
$hasCoverageWarnings = in_array($stats->coverageStatus, ['warning', 'unproven'], true);
|
||||||
$hasEvidenceGaps = (int) ($stats->evidenceGapsCount ?? 0) > 0;
|
$hasEvidenceGaps = (int) ($stats->evidenceGapsCount ?? 0) > 0;
|
||||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||||
@ -42,8 +44,8 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
? ExplanationFamily::tryFrom(str_replace('true_no_result', 'no_issues_detected', $reason->absencePattern)) ?? null
|
? ExplanationFamily::tryFrom(str_replace('true_no_result', 'no_issues_detected', $reason->absencePattern)) ?? null
|
||||||
: null;
|
: null;
|
||||||
$family ??= match (true) {
|
$family ??= match (true) {
|
||||||
$stats->state === 'comparing' => ExplanationFamily::InProgress,
|
$isInProgress => ExplanationFamily::InProgress,
|
||||||
$stats->state === 'failed' => ExplanationFamily::BlockedPrerequisite,
|
$isFailed => ExplanationFamily::BlockedPrerequisite,
|
||||||
$stats->state === 'no_tenant',
|
$stats->state === 'no_tenant',
|
||||||
$stats->state === 'no_assignment',
|
$stats->state === 'no_assignment',
|
||||||
$stats->state === 'no_snapshot',
|
$stats->state === 'no_snapshot',
|
||||||
@ -62,59 +64,69 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
$family === ExplanationFamily::InProgress => TrustworthinessLevel::DiagnosticOnly,
|
$family === ExplanationFamily::InProgress => TrustworthinessLevel::DiagnosticOnly,
|
||||||
default => TrustworthinessLevel::Unusable,
|
default => TrustworthinessLevel::Unusable,
|
||||||
};
|
};
|
||||||
$evaluationResult = match ($family) {
|
$evaluationResult = $isFailed
|
||||||
ExplanationFamily::TrustworthyResult => 'full_result',
|
? 'failed_result'
|
||||||
ExplanationFamily::NoIssuesDetected => 'no_result',
|
: match ($family) {
|
||||||
ExplanationFamily::SuppressedOutput => 'suppressed_result',
|
ExplanationFamily::TrustworthyResult => 'full_result',
|
||||||
ExplanationFamily::MissingInput,
|
ExplanationFamily::NoIssuesDetected => 'no_result',
|
||||||
ExplanationFamily::BlockedPrerequisite,
|
ExplanationFamily::SuppressedOutput => 'suppressed_result',
|
||||||
ExplanationFamily::Unavailable,
|
ExplanationFamily::MissingInput,
|
||||||
ExplanationFamily::InProgress => 'unavailable',
|
ExplanationFamily::BlockedPrerequisite,
|
||||||
ExplanationFamily::CompletedButLimited => $reason?->absencePattern === 'suppressed_output'
|
ExplanationFamily::Unavailable,
|
||||||
? 'suppressed_result'
|
ExplanationFamily::InProgress => 'unavailable',
|
||||||
: 'incomplete_result',
|
ExplanationFamily::CompletedButLimited => $reason?->absencePattern === 'suppressed_output'
|
||||||
};
|
? 'suppressed_result'
|
||||||
$headline = match ($family) {
|
: 'incomplete_result',
|
||||||
ExplanationFamily::NoIssuesDetected => 'The comparison found no actionable drift.',
|
};
|
||||||
ExplanationFamily::TrustworthyResult => 'The comparison produced a usable drift result.',
|
$headline = match (true) {
|
||||||
ExplanationFamily::CompletedButLimited => $findingsCount > 0
|
$isFailed => 'The comparison failed before it produced a usable result.',
|
||||||
? 'The comparison found drift, but the result needs caution.'
|
default => match ($family) {
|
||||||
: 'The comparison finished, but the current result is not an all-clear.',
|
ExplanationFamily::NoIssuesDetected => 'The comparison found no actionable drift.',
|
||||||
ExplanationFamily::SuppressedOutput => 'The comparison finished, but normal result output was suppressed.',
|
ExplanationFamily::TrustworthyResult => 'The comparison produced a usable drift result.',
|
||||||
ExplanationFamily::MissingInput => 'The comparison could not produce a usable result because required inputs were missing.',
|
ExplanationFamily::CompletedButLimited => $findingsCount > 0
|
||||||
ExplanationFamily::BlockedPrerequisite => 'The comparison could not produce a usable result because a prerequisite blocked it.',
|
? 'The comparison found drift, but the result needs caution.'
|
||||||
ExplanationFamily::InProgress => 'The comparison is still running.',
|
: 'The comparison finished, but the current result is not an all-clear.',
|
||||||
ExplanationFamily::Unavailable => 'A usable comparison result is not currently available.',
|
ExplanationFamily::SuppressedOutput => 'The comparison finished, but normal result output was suppressed.',
|
||||||
|
ExplanationFamily::MissingInput => 'The comparison could not produce a usable result because required inputs were missing.',
|
||||||
|
ExplanationFamily::BlockedPrerequisite => 'The comparison could not produce a usable result because a prerequisite blocked it.',
|
||||||
|
ExplanationFamily::InProgress => 'The comparison is still running.',
|
||||||
|
ExplanationFamily::Unavailable => 'A usable comparison result is not currently available.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
$coverageStatement = match (true) {
|
$coverageStatement = match (true) {
|
||||||
$stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.',
|
$stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.',
|
||||||
|
$isFailed => 'The last compare run did not finish cleanly, so current counts should not be treated as decision-grade.',
|
||||||
$stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.',
|
$stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.',
|
||||||
$stats->uncoveredTypes !== [] => 'One or more in-scope policy types were not fully covered in this compare run.',
|
$stats->uncoveredTypes !== [] => 'One or more in-scope policy types were not fully covered in this compare run.',
|
||||||
$hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.',
|
$hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.',
|
||||||
$stats->state === 'comparing' => 'Counts will become decision-grade after the compare run finishes.',
|
$isInProgress => 'Counts will become decision-grade after the compare run finishes.',
|
||||||
in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.',
|
in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.',
|
||||||
default => 'Coverage matched the in-scope compare input for this run.',
|
default => 'Coverage matched the in-scope compare input for this run.',
|
||||||
};
|
};
|
||||||
$reliabilityStatement = match ($trustworthiness) {
|
$reliabilityStatement = $isFailed
|
||||||
TrustworthinessLevel::Trustworthy => $findingsCount > 0
|
? 'The last compare failed, so the tenant needs review before you rely on this posture.'
|
||||||
? 'The compare completed with enough coverage to treat the recorded drift as decision-grade.'
|
: match ($trustworthiness) {
|
||||||
: 'The compare completed with enough coverage to treat the absence of findings as trustworthy.',
|
TrustworthinessLevel::Trustworthy => $findingsCount > 0
|
||||||
TrustworthinessLevel::LimitedConfidence => 'The compare completed, but coverage or evidence limitations mean the visible result should be treated as partial.',
|
? 'The compare completed with enough coverage to treat the recorded drift as decision-grade.'
|
||||||
TrustworthinessLevel::DiagnosticOnly => 'The compare is still in progress, so current counts are only diagnostic.',
|
: 'The compare completed with enough coverage to treat the absence of findings as trustworthy.',
|
||||||
TrustworthinessLevel::Unusable => 'The compare did not produce a result that should be used for the intended decision yet.',
|
TrustworthinessLevel::LimitedConfidence => 'The compare completed, but coverage or evidence limitations mean the visible result should be treated as partial.',
|
||||||
};
|
TrustworthinessLevel::DiagnosticOnly => 'The compare is still in progress, so current counts are only diagnostic.',
|
||||||
$nextActionText = $reason?->firstNextStep()?->label ?? match ($family) {
|
TrustworthinessLevel::Unusable => 'The compare did not produce a result that should be used for the intended decision yet.',
|
||||||
ExplanationFamily::NoIssuesDetected => 'No action needed',
|
};
|
||||||
ExplanationFamily::TrustworthyResult => 'Review the detected drift findings',
|
$nextActionText = $isFailed
|
||||||
ExplanationFamily::SuppressedOutput => 'Review the missing coverage or evidence before treating this compare as complete',
|
? 'Review the failed compare run before relying on this tenant posture'
|
||||||
ExplanationFamily::CompletedButLimited => 'Review coverage limits before acting on this compare',
|
: ($reason?->firstNextStep()?->label ?? match ($family) {
|
||||||
ExplanationFamily::InProgress => 'Wait for the compare to finish',
|
ExplanationFamily::NoIssuesDetected => 'No action needed',
|
||||||
ExplanationFamily::MissingInput,
|
ExplanationFamily::TrustworthyResult => 'Review the detected drift findings',
|
||||||
ExplanationFamily::BlockedPrerequisite,
|
ExplanationFamily::SuppressedOutput => 'Review the missing coverage or evidence before treating this compare as complete',
|
||||||
ExplanationFamily::Unavailable => $stats->state === 'idle'
|
ExplanationFamily::CompletedButLimited => 'Review coverage limits before acting on this compare',
|
||||||
? 'Run the baseline compare to generate a result'
|
ExplanationFamily::InProgress => 'Wait for the compare to finish',
|
||||||
: 'Review the blocking baseline or scope prerequisite',
|
ExplanationFamily::MissingInput,
|
||||||
};
|
ExplanationFamily::BlockedPrerequisite,
|
||||||
|
ExplanationFamily::Unavailable => $stats->state === 'idle'
|
||||||
|
? 'Run the baseline compare to generate a result'
|
||||||
|
: 'Review the blocking baseline or scope prerequisite',
|
||||||
|
});
|
||||||
|
|
||||||
return $this->builder->build(
|
return $this->builder->build(
|
||||||
family: $family,
|
family: $family,
|
||||||
@ -128,15 +140,17 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
dominantCauseCode: $reason?->internalCode ?? $stats->reasonCode,
|
dominantCauseCode: $reason?->internalCode ?? $stats->reasonCode,
|
||||||
dominantCauseLabel: $reason?->operatorLabel,
|
dominantCauseLabel: $reason?->operatorLabel,
|
||||||
dominantCauseExplanation: $reason?->shortExplanation ?? $stats->message ?? $stats->failureReason,
|
dominantCauseExplanation: $reason?->shortExplanation ?? $stats->message ?? $stats->failureReason,
|
||||||
nextActionCategory: $family === ExplanationFamily::NoIssuesDetected
|
nextActionCategory: $isFailed
|
||||||
? 'none'
|
? 'inspect_run'
|
||||||
: match ($family) {
|
: ($family === ExplanationFamily::NoIssuesDetected
|
||||||
ExplanationFamily::TrustworthyResult => 'manual_validate',
|
? 'none'
|
||||||
ExplanationFamily::MissingInput,
|
: match ($family) {
|
||||||
ExplanationFamily::BlockedPrerequisite,
|
ExplanationFamily::TrustworthyResult => 'manual_validate',
|
||||||
ExplanationFamily::Unavailable => 'fix_prerequisite',
|
ExplanationFamily::MissingInput,
|
||||||
default => 'review_evidence_gaps',
|
ExplanationFamily::BlockedPrerequisite,
|
||||||
},
|
ExplanationFamily::Unavailable => 'fix_prerequisite',
|
||||||
|
default => 'review_evidence_gaps',
|
||||||
|
}),
|
||||||
nextActionText: $nextActionText,
|
nextActionText: $nextActionText,
|
||||||
countDescriptors: $this->countDescriptors($stats, $hasCoverageWarnings, $hasEvidenceGaps),
|
countDescriptors: $this->countDescriptors($stats, $hasCoverageWarnings, $hasEvidenceGaps),
|
||||||
diagnosticsAvailable: $stats->operationRunId !== null || $reason !== null,
|
diagnosticsAvailable: $stats->operationRunId !== null || $reason !== null,
|
||||||
|
|||||||
@ -58,4 +58,9 @@ public function absencePattern(): ?string
|
|||||||
self::NoSubjectsInScope => 'missing_input',
|
self::NoSubjectsInScope => 'missing_input',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function supportsPositiveClaim(): bool
|
||||||
|
{
|
||||||
|
return $this === self::NoDriftDetected;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -713,6 +713,14 @@ public function operatorExplanation(): OperatorExplanationPattern
|
|||||||
return $registry->forStats($this);
|
return $registry->forStats($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function summaryAssessment(): BaselineCompareSummaryAssessment
|
||||||
|
{
|
||||||
|
/** @var BaselineCompareSummaryAssessor $assessor */
|
||||||
|
$assessor = app(BaselineCompareSummaryAssessor::class);
|
||||||
|
|
||||||
|
return $assessor->assess($this);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array{
|
* @return array<int, array{
|
||||||
* label: string,
|
* label: string,
|
||||||
|
|||||||
150
app/Support/Baselines/BaselineCompareSummaryAssessment.php
Normal file
150
app/Support/Baselines/BaselineCompareSummaryAssessment.php
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class BaselineCompareSummaryAssessment
|
||||||
|
{
|
||||||
|
public const string STATE_POSITIVE = 'positive';
|
||||||
|
|
||||||
|
public const string STATE_CAUTION = 'caution';
|
||||||
|
|
||||||
|
public const string STATE_STALE = 'stale';
|
||||||
|
|
||||||
|
public const string STATE_ACTION_REQUIRED = 'action_required';
|
||||||
|
|
||||||
|
public const string STATE_UNAVAILABLE = 'unavailable';
|
||||||
|
|
||||||
|
public const string STATE_IN_PROGRESS = 'in_progress';
|
||||||
|
|
||||||
|
public const string EVIDENCE_NONE = 'none';
|
||||||
|
|
||||||
|
public const string EVIDENCE_COVERAGE_WARNING = 'coverage_warning';
|
||||||
|
|
||||||
|
public const string EVIDENCE_EVIDENCE_GAP = 'evidence_gap';
|
||||||
|
|
||||||
|
public const string EVIDENCE_STALE_RESULT = 'stale_result';
|
||||||
|
|
||||||
|
public const string EVIDENCE_SUPPRESSED_OUTPUT = 'suppressed_output';
|
||||||
|
|
||||||
|
public const string EVIDENCE_UNAVAILABLE = 'unavailable';
|
||||||
|
|
||||||
|
public const string NEXT_TARGET_LANDING = 'landing';
|
||||||
|
|
||||||
|
public const string NEXT_TARGET_FINDINGS = 'findings';
|
||||||
|
|
||||||
|
public const string NEXT_TARGET_RUN = 'run';
|
||||||
|
|
||||||
|
public const string NEXT_TARGET_NONE = 'none';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{label: string, target: string} $nextAction
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $stateFamily,
|
||||||
|
public string $headline,
|
||||||
|
public ?string $supportingMessage,
|
||||||
|
public string $tone,
|
||||||
|
public bool $positiveClaimAllowed,
|
||||||
|
public string $trustworthinessLevel,
|
||||||
|
public string $evaluationResult,
|
||||||
|
public string $evidenceImpact,
|
||||||
|
public int $findingsVisibleCount,
|
||||||
|
public int $highSeverityCount,
|
||||||
|
public array $nextAction,
|
||||||
|
public ?string $lastComparedLabel = null,
|
||||||
|
public ?string $reasonCode = null,
|
||||||
|
) {
|
||||||
|
if (! in_array($this->stateFamily, [
|
||||||
|
self::STATE_POSITIVE,
|
||||||
|
self::STATE_CAUTION,
|
||||||
|
self::STATE_STALE,
|
||||||
|
self::STATE_ACTION_REQUIRED,
|
||||||
|
self::STATE_UNAVAILABLE,
|
||||||
|
self::STATE_IN_PROGRESS,
|
||||||
|
], true)) {
|
||||||
|
throw new InvalidArgumentException('Unsupported baseline summary state family: '.$this->stateFamily);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($this->headline) === '') {
|
||||||
|
throw new InvalidArgumentException('Baseline summary assessments require a headline.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($this->evidenceImpact, [
|
||||||
|
self::EVIDENCE_NONE,
|
||||||
|
self::EVIDENCE_COVERAGE_WARNING,
|
||||||
|
self::EVIDENCE_EVIDENCE_GAP,
|
||||||
|
self::EVIDENCE_STALE_RESULT,
|
||||||
|
self::EVIDENCE_SUPPRESSED_OUTPUT,
|
||||||
|
self::EVIDENCE_UNAVAILABLE,
|
||||||
|
], true)) {
|
||||||
|
throw new InvalidArgumentException('Unsupported baseline summary evidence impact: '.$this->evidenceImpact);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($this->nextAction['target'] ?? null, [
|
||||||
|
self::NEXT_TARGET_LANDING,
|
||||||
|
self::NEXT_TARGET_FINDINGS,
|
||||||
|
self::NEXT_TARGET_RUN,
|
||||||
|
self::NEXT_TARGET_NONE,
|
||||||
|
], true)) {
|
||||||
|
throw new InvalidArgumentException('Unsupported baseline summary next-action target.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim((string) ($this->nextAction['label'] ?? '')) === '') {
|
||||||
|
throw new InvalidArgumentException('Baseline summary assessments require a next-action label.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->positiveClaimAllowed && $this->stateFamily !== self::STATE_POSITIVE) {
|
||||||
|
throw new InvalidArgumentException('Positive claim eligibility must resolve to the positive summary state.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextActionLabel(): string
|
||||||
|
{
|
||||||
|
return $this->nextAction['label'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextActionTarget(): string
|
||||||
|
{
|
||||||
|
return $this->nextAction['target'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* stateFamily: string,
|
||||||
|
* headline: string,
|
||||||
|
* supportingMessage: ?string,
|
||||||
|
* tone: string,
|
||||||
|
* positiveClaimAllowed: bool,
|
||||||
|
* trustworthinessLevel: string,
|
||||||
|
* evaluationResult: string,
|
||||||
|
* evidenceImpact: string,
|
||||||
|
* findingsVisibleCount: int,
|
||||||
|
* highSeverityCount: int,
|
||||||
|
* nextAction: array{label: string, target: string},
|
||||||
|
* lastComparedLabel: ?string,
|
||||||
|
* reasonCode: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'stateFamily' => $this->stateFamily,
|
||||||
|
'headline' => $this->headline,
|
||||||
|
'supportingMessage' => $this->supportingMessage,
|
||||||
|
'tone' => $this->tone,
|
||||||
|
'positiveClaimAllowed' => $this->positiveClaimAllowed,
|
||||||
|
'trustworthinessLevel' => $this->trustworthinessLevel,
|
||||||
|
'evaluationResult' => $this->evaluationResult,
|
||||||
|
'evidenceImpact' => $this->evidenceImpact,
|
||||||
|
'findingsVisibleCount' => $this->findingsVisibleCount,
|
||||||
|
'highSeverityCount' => $this->highSeverityCount,
|
||||||
|
'nextAction' => $this->nextAction,
|
||||||
|
'lastComparedLabel' => $this->lastComparedLabel,
|
||||||
|
'reasonCode' => $this->reasonCode,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
285
app/Support/Baselines/BaselineCompareSummaryAssessor.php
Normal file
285
app/Support/Baselines/BaselineCompareSummaryAssessor.php
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
|
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
|
final class BaselineCompareSummaryAssessor
|
||||||
|
{
|
||||||
|
private const int STALE_AFTER_DAYS = 7;
|
||||||
|
|
||||||
|
public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment
|
||||||
|
{
|
||||||
|
$explanation = $stats->operatorExplanation();
|
||||||
|
$findingsVisibleCount = (int) ($stats->findingsCount ?? 0);
|
||||||
|
$highSeverityCount = (int) ($stats->severityCounts['high'] ?? 0);
|
||||||
|
$reasonCode = is_string($stats->reasonCode) ? BaselineCompareReasonCode::tryFrom($stats->reasonCode) : null;
|
||||||
|
$evaluationResult = $stats->state === 'failed'
|
||||||
|
? 'failed_result'
|
||||||
|
: $explanation->evaluationResult;
|
||||||
|
$positiveClaimAllowed = $this->positiveClaimAllowed($stats, $explanation, $reasonCode, $evaluationResult);
|
||||||
|
$isStale = $this->hasStaleResult($stats, $evaluationResult);
|
||||||
|
$stateFamily = $this->stateFamily($stats, $findingsVisibleCount, $positiveClaimAllowed, $isStale);
|
||||||
|
|
||||||
|
return new BaselineCompareSummaryAssessment(
|
||||||
|
stateFamily: $stateFamily,
|
||||||
|
headline: $this->headline($stats, $stateFamily, $findingsVisibleCount, $highSeverityCount, $evaluationResult),
|
||||||
|
supportingMessage: $this->supportingMessage($stats, $stateFamily, $findingsVisibleCount, $evaluationResult),
|
||||||
|
tone: $this->tone($stats, $stateFamily),
|
||||||
|
positiveClaimAllowed: $positiveClaimAllowed,
|
||||||
|
trustworthinessLevel: $explanation->trustworthinessLevel->value,
|
||||||
|
evaluationResult: $evaluationResult,
|
||||||
|
evidenceImpact: $this->evidenceImpact($stats, $evaluationResult, $isStale),
|
||||||
|
findingsVisibleCount: $findingsVisibleCount,
|
||||||
|
highSeverityCount: $highSeverityCount,
|
||||||
|
nextAction: $this->nextAction($stats, $stateFamily, $findingsVisibleCount, $evaluationResult),
|
||||||
|
lastComparedLabel: $stats->lastComparedHuman,
|
||||||
|
reasonCode: $stats->reasonCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function positiveClaimAllowed(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
OperatorExplanationPattern $explanation,
|
||||||
|
?BaselineCompareReasonCode $reasonCode,
|
||||||
|
string $evaluationResult,
|
||||||
|
): bool {
|
||||||
|
if ($stats->state !== 'ready') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($stats->findingsCount ?? 0) > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evaluationResult !== 'no_result') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($explanation->trustworthinessLevel !== TrustworthinessLevel::Trustworthy) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($stats->coverageStatus, ['warning', 'unproven'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($stats->evidenceGapsCount ?? 0) > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasStaleResult($stats, $evaluationResult)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->reasonCode === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reasonCode?->supportsPositiveClaim() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stateFamily(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
int $findingsVisibleCount,
|
||||||
|
bool $positiveClaimAllowed,
|
||||||
|
bool $isStale,
|
||||||
|
): string {
|
||||||
|
return match (true) {
|
||||||
|
$stats->state === 'comparing' => BaselineCompareSummaryAssessment::STATE_IN_PROGRESS,
|
||||||
|
$stats->state === 'failed',
|
||||||
|
$findingsVisibleCount > 0 => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
|
||||||
|
in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle'], true) => BaselineCompareSummaryAssessment::STATE_UNAVAILABLE,
|
||||||
|
$isStale => BaselineCompareSummaryAssessment::STATE_STALE,
|
||||||
|
$positiveClaimAllowed => BaselineCompareSummaryAssessment::STATE_POSITIVE,
|
||||||
|
default => BaselineCompareSummaryAssessment::STATE_CAUTION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evidenceImpact(BaselineCompareStats $stats, string $evaluationResult, bool $isStale): string
|
||||||
|
{
|
||||||
|
if (in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle', 'failed'], true)) {
|
||||||
|
return BaselineCompareSummaryAssessment::EVIDENCE_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isStale) {
|
||||||
|
return BaselineCompareSummaryAssessment::EVIDENCE_STALE_RESULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evaluationResult === 'suppressed_result') {
|
||||||
|
return BaselineCompareSummaryAssessment::EVIDENCE_SUPPRESSED_OUTPUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($stats->evidenceGapsCount ?? 0) > 0) {
|
||||||
|
return BaselineCompareSummaryAssessment::EVIDENCE_EVIDENCE_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($stats->coverageStatus, ['warning', 'unproven'], true)) {
|
||||||
|
return BaselineCompareSummaryAssessment::EVIDENCE_COVERAGE_WARNING;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaselineCompareSummaryAssessment::EVIDENCE_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function headline(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
string $stateFamily,
|
||||||
|
int $findingsVisibleCount,
|
||||||
|
int $highSeverityCount,
|
||||||
|
string $evaluationResult,
|
||||||
|
): string {
|
||||||
|
return match ($stateFamily) {
|
||||||
|
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'No confirmed drift in the latest baseline compare.',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_CAUTION => match (true) {
|
||||||
|
$evaluationResult === 'suppressed_result' => 'The last compare finished, but normal result output was suppressed.',
|
||||||
|
(int) ($stats->evidenceGapsCount ?? 0) > 0 => 'No confirmed drift is visible, but evidence gaps still limit this result.',
|
||||||
|
in_array($stats->coverageStatus, ['warning', 'unproven'], true) => 'No confirmed drift is visible, but coverage limits this compare.',
|
||||||
|
default => 'The latest compare result needs caution before you treat it as an all-clear.',
|
||||||
|
},
|
||||||
|
BaselineCompareSummaryAssessment::STATE_STALE => 'The latest baseline compare result is stale.',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
||||||
|
$stats->state === 'failed' || $evaluationResult === 'failed_result' => 'The latest baseline compare failed before it produced a usable result.',
|
||||||
|
$highSeverityCount > 0 => sprintf('%d high-severity drift finding%s need review.', $highSeverityCount, $highSeverityCount === 1 ? '' : 's'),
|
||||||
|
default => sprintf('%d open drift finding%s need review.', $findingsVisibleCount, $findingsVisibleCount === 1 ? '' : 's'),
|
||||||
|
},
|
||||||
|
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'Baseline compare is in progress.',
|
||||||
|
default => match ($stats->state) {
|
||||||
|
'no_assignment' => 'This tenant does not have an assigned baseline yet.',
|
||||||
|
'no_snapshot' => 'The current baseline snapshot is not available for compare.',
|
||||||
|
'idle' => 'A current baseline compare result is not available yet.',
|
||||||
|
default => 'A usable baseline compare result is not currently available.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function supportingMessage(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
string $stateFamily,
|
||||||
|
int $findingsVisibleCount,
|
||||||
|
string $evaluationResult,
|
||||||
|
): ?string {
|
||||||
|
return match ($stateFamily) {
|
||||||
|
BaselineCompareSummaryAssessment::STATE_POSITIVE => $stats->lastComparedHuman !== null
|
||||||
|
? 'Last compared '.$stats->lastComparedHuman.'.'
|
||||||
|
: 'The latest compare result is trustworthy enough to treat zero findings as current.',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_CAUTION => match (true) {
|
||||||
|
$evaluationResult === 'suppressed_result' => 'Review the run detail before treating zero visible findings as complete.',
|
||||||
|
(int) ($stats->evidenceGapsCount ?? 0) > 0 => 'Review the compare detail to see which evidence gaps still limit trust.',
|
||||||
|
in_array($stats->coverageStatus, ['warning', 'unproven'], true) => 'Coverage warnings mean zero visible findings are not an all-clear on their own.',
|
||||||
|
default => $stats->reasonMessage ?? $stats->message,
|
||||||
|
},
|
||||||
|
BaselineCompareSummaryAssessment::STATE_STALE => $stats->lastComparedHuman !== null
|
||||||
|
? 'Last compared '.$stats->lastComparedHuman.'. Refresh compare before relying on this posture.'
|
||||||
|
: 'Refresh compare before relying on this posture.',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
||||||
|
$stats->state === 'failed' => $stats->failureReason,
|
||||||
|
$findingsVisibleCount > 0 => 'Open findings remain on this tenant and need review.',
|
||||||
|
default => $stats->message,
|
||||||
|
},
|
||||||
|
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'Current counts are diagnostic only until the compare run finishes.',
|
||||||
|
default => $stats->message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tone(BaselineCompareStats $stats, string $stateFamily): string
|
||||||
|
{
|
||||||
|
return match ($stateFamily) {
|
||||||
|
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'success',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => 'danger',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'info',
|
||||||
|
BaselineCompareSummaryAssessment::STATE_UNAVAILABLE => $stats->state === 'no_snapshot' ? 'warning' : 'gray',
|
||||||
|
default => 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, target: string}
|
||||||
|
*/
|
||||||
|
private function nextAction(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
string $stateFamily,
|
||||||
|
int $findingsVisibleCount,
|
||||||
|
string $evaluationResult,
|
||||||
|
): array {
|
||||||
|
if ($findingsVisibleCount > 0) {
|
||||||
|
return [
|
||||||
|
'label' => 'Open findings',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($stateFamily) {
|
||||||
|
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => [
|
||||||
|
'label' => $evaluationResult === 'failed_result' ? 'Review the failed run' : 'Review compare detail',
|
||||||
|
'target' => $stats->operationRunId !== null
|
||||||
|
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
|
||||||
|
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
],
|
||||||
|
BaselineCompareSummaryAssessment::STATE_CAUTION => [
|
||||||
|
'label' => 'Review compare detail',
|
||||||
|
'target' => $stats->operationRunId !== null
|
||||||
|
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
|
||||||
|
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
],
|
||||||
|
BaselineCompareSummaryAssessment::STATE_STALE => [
|
||||||
|
'label' => 'Open Baseline Compare',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
],
|
||||||
|
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => [
|
||||||
|
'label' => $stats->operationRunId !== null ? 'View run' : 'Open Baseline Compare',
|
||||||
|
'target' => $stats->operationRunId !== null
|
||||||
|
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
|
||||||
|
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
],
|
||||||
|
BaselineCompareSummaryAssessment::STATE_UNAVAILABLE => match ($stats->state) {
|
||||||
|
'no_assignment' => [
|
||||||
|
'label' => 'Assign a baseline first',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
||||||
|
],
|
||||||
|
'no_snapshot' => [
|
||||||
|
'label' => 'Review baseline prerequisites',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
],
|
||||||
|
'idle' => [
|
||||||
|
'label' => 'Open Baseline Compare',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'label' => 'Review compare availability',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
default => [
|
||||||
|
'label' => 'No action needed',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasStaleResult(BaselineCompareStats $stats, string $evaluationResult): bool
|
||||||
|
{
|
||||||
|
if ($stats->state !== 'ready') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->lastComparedIso === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($evaluationResult, ['full_result', 'no_result', 'incomplete_result', 'suppressed_result'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -220,9 +220,9 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
|
|||||||
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($enum) {
|
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($enum) {
|
||||||
BaselineCompareReasonCode::NoDriftDetected => [
|
BaselineCompareReasonCode::NoDriftDetected => [
|
||||||
'No drift detected',
|
'No drift detected',
|
||||||
'The comparison completed for the in-scope subjects without recording drift findings.',
|
'The comparison completed with enough coverage to treat the absence of drift findings as trustworthy.',
|
||||||
'non_actionable',
|
'non_actionable',
|
||||||
'No action needed unless you expected findings.',
|
'No action needed unless you expected a newer compare result.',
|
||||||
],
|
],
|
||||||
BaselineCompareReasonCode::CoverageUnproven => [
|
BaselineCompareReasonCode::CoverageUnproven => [
|
||||||
'Coverage proof missing',
|
'Coverage proof missing',
|
||||||
|
|||||||
@ -69,10 +69,10 @@
|
|||||||
'comparing_indicator' => 'Comparing…',
|
'comparing_indicator' => 'Comparing…',
|
||||||
|
|
||||||
// Why-no-findings explanations
|
// Why-no-findings explanations
|
||||||
'no_findings_all_clear' => 'All clear',
|
'no_findings_all_clear' => 'No confirmed drift in the latest compare',
|
||||||
'no_findings_coverage_warnings' => 'Coverage warnings',
|
'no_findings_coverage_warnings' => 'No drift is shown, but coverage limits this compare',
|
||||||
'no_findings_evidence_gaps' => 'Evidence gaps',
|
'no_findings_evidence_gaps' => 'No drift is shown, but evidence gaps still need review',
|
||||||
'no_findings_default' => 'No findings',
|
'no_findings_default' => 'No drift findings are currently visible',
|
||||||
|
|
||||||
// Coverage warning banner
|
// Coverage warning banner
|
||||||
'coverage_warning_title' => 'Comparison completed with warnings',
|
'coverage_warning_title' => 'Comparison completed with warnings',
|
||||||
@ -105,11 +105,11 @@
|
|||||||
|
|
||||||
// No drift
|
// No drift
|
||||||
'no_drift_title' => 'No Drift Detected',
|
'no_drift_title' => 'No Drift Detected',
|
||||||
'no_drift_body' => 'The tenant configuration matches the baseline profile. Everything looks good.',
|
'no_drift_body' => 'The latest compare recorded no confirmed drift for the assigned baseline profile.',
|
||||||
|
|
||||||
// Coverage warnings (no findings)
|
// Coverage warnings (no findings)
|
||||||
'coverage_warnings_title' => 'Coverage Warnings',
|
'coverage_warnings_title' => 'Coverage Warnings',
|
||||||
'coverage_warnings_body' => 'The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results.',
|
'coverage_warnings_body' => 'The last comparison completed with warnings and produced no confirmed drift findings. Refresh evidence before treating the result as an all-clear.',
|
||||||
|
|
||||||
// Idle
|
// Idle
|
||||||
'idle_title' => 'Ready to Compare',
|
'idle_title' => 'Ready to Compare',
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
|
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
|
||||||
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
|
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
|
||||||
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
|
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
|
||||||
|
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
|
||||||
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
|
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
|
||||||
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
|
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
|
||||||
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
|
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
|
||||||
@ -15,6 +16,14 @@
|
|||||||
$trustSpec = is_string($explanation['trustworthinessLevel'] ?? null)
|
$trustSpec = is_string($explanation['trustworthinessLevel'] ?? null)
|
||||||
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationTrustworthiness, $explanation['trustworthinessLevel'])
|
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationTrustworthiness, $explanation['trustworthinessLevel'])
|
||||||
: null;
|
: null;
|
||||||
|
$summaryLabel = match ($summary['stateFamily'] ?? null) {
|
||||||
|
'positive' => 'Aligned',
|
||||||
|
'caution' => 'Needs review',
|
||||||
|
'stale' => 'Refresh recommended',
|
||||||
|
'action_required' => 'Action required',
|
||||||
|
'in_progress' => 'In progress',
|
||||||
|
default => 'Unavailable',
|
||||||
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if ($duplicateNamePoliciesCountValue > 0)
|
@if ($duplicateNamePoliciesCountValue > 0)
|
||||||
@ -41,6 +50,12 @@
|
|||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex flex-wrap items-start gap-2">
|
<div class="flex flex-wrap items-start gap-2">
|
||||||
|
@if ($summary)
|
||||||
|
<x-filament::badge :color="$summary['tone'] ?? 'gray'" size="sm">
|
||||||
|
{{ $summaryLabel }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
|
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
|
||||||
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
|
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
|
||||||
{{ $evaluationSpec->label }}
|
{{ $evaluationSpec->label }}
|
||||||
@ -56,9 +71,15 @@
|
|||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">
|
<div class="text-lg font-semibold text-gray-950 dark:text-white">
|
||||||
{{ $explanation['headline'] ?? 'Compare explanation' }}
|
{{ $summary['headline'] ?? ($explanation['headline'] ?? 'Compare explanation') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (filled($summary['supportingMessage'] ?? null))
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
{{ $summary['supportingMessage'] }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if (filled($explanation['reliabilityStatement'] ?? null))
|
@if (filled($explanation['reliabilityStatement'] ?? null))
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-200">
|
<p class="text-sm text-gray-700 dark:text-gray-200">
|
||||||
{{ $explanation['reliabilityStatement'] }}
|
{{ $explanation['reliabilityStatement'] }}
|
||||||
@ -90,7 +111,7 @@
|
|||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50 md:col-span-2">
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50 md:col-span-2">
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">What to do next</dt>
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">What to do next</dt>
|
||||||
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{{ data_get($explanation, 'nextAction.text', 'Review the latest compare run.') }}
|
{{ data_get($summary, 'nextAction.label') ?? data_get($explanation, 'nextAction.text', 'Review the latest compare run.') }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@ -190,7 +211,15 @@ class="w-fit"
|
|||||||
{{ __('baseline-compare.comparing_indicator') }}
|
{{ __('baseline-compare.comparing_indicator') }}
|
||||||
</div>
|
</div>
|
||||||
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
|
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
|
||||||
<span class="text-sm {{ $whyNoFindingsColor }}">{{ $whyNoFindingsMessage ?? $whyNoFindingsFallback }}</span>
|
<div class="space-y-1">
|
||||||
|
<span class="text-sm {{ $whyNoFindingsColor }}">{{ $summary['headline'] ?? ($whyNoFindingsMessage ?? $whyNoFindingsFallback) }}</span>
|
||||||
|
|
||||||
|
@if (filled($summary['supportingMessage'] ?? null))
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $summary['supportingMessage'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|||||||
@ -1,3 +1,58 @@
|
|||||||
|
@php
|
||||||
|
/** @var array<string, mixed>|null $summaryAssessment */
|
||||||
|
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
|
||||||
|
$summaryState = (string) ($summary['stateFamily'] ?? 'unavailable');
|
||||||
|
$summaryTone = (string) ($summary['tone'] ?? 'gray');
|
||||||
|
$findingsCount = (int) ($summary['findingsVisibleCount'] ?? 0);
|
||||||
|
$highSeverityCount = (int) ($summary['highSeverityCount'] ?? 0);
|
||||||
|
$nextAction = is_array($summary['nextAction'] ?? null) ? $summary['nextAction'] : ['label' => 'Review baseline compare', 'target' => 'none'];
|
||||||
|
|
||||||
|
$summaryLabel = match ($summaryState) {
|
||||||
|
'positive' => 'Aligned',
|
||||||
|
'caution' => 'Needs review',
|
||||||
|
'stale' => 'Refresh recommended',
|
||||||
|
'action_required' => 'Action required',
|
||||||
|
'in_progress' => 'In progress',
|
||||||
|
default => 'Unavailable',
|
||||||
|
};
|
||||||
|
|
||||||
|
[$cardClasses, $iconClasses, $textClasses] = match ($summaryTone) {
|
||||||
|
'success' => [
|
||||||
|
'rounded-lg border border-success-300 bg-success-50 p-4 dark:border-success-700 dark:bg-success-950/40',
|
||||||
|
'h-5 w-5 shrink-0 text-success-600 dark:text-success-400',
|
||||||
|
'text-success-900 dark:text-success-100',
|
||||||
|
],
|
||||||
|
'danger' => [
|
||||||
|
'rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/40',
|
||||||
|
'h-5 w-5 shrink-0 text-danger-600 dark:text-danger-400',
|
||||||
|
'text-danger-900 dark:text-danger-100',
|
||||||
|
],
|
||||||
|
'info' => [
|
||||||
|
'rounded-lg border border-info-300 bg-info-50 p-4 dark:border-info-700 dark:bg-info-950/40',
|
||||||
|
'h-5 w-5 shrink-0 text-info-600 dark:text-info-400',
|
||||||
|
'text-info-900 dark:text-info-100',
|
||||||
|
],
|
||||||
|
'warning' => [
|
||||||
|
'rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40',
|
||||||
|
'h-5 w-5 shrink-0 text-warning-600 dark:text-warning-400',
|
||||||
|
'text-warning-900 dark:text-warning-100',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-white/5',
|
||||||
|
'h-5 w-5 shrink-0 text-gray-500 dark:text-gray-400',
|
||||||
|
'text-gray-900 dark:text-white',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
$summaryIcon = match ($summaryState) {
|
||||||
|
'positive' => 'heroicon-o-check-circle',
|
||||||
|
'action_required' => 'heroicon-o-exclamation-triangle',
|
||||||
|
'in_progress' => 'heroicon-o-arrow-path',
|
||||||
|
'stale' => 'heroicon-o-clock',
|
||||||
|
default => 'heroicon-o-information-circle',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
<x-filament::section heading="Baseline Governance">
|
<x-filament::section heading="Baseline Governance">
|
||||||
@if ($landingUrl)
|
@if ($landingUrl)
|
||||||
<x-slot name="afterHeader">
|
<x-slot name="afterHeader">
|
||||||
@ -15,63 +70,67 @@
|
|||||||
<div class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Assign a baseline profile to start monitoring drift.</div>
|
<div class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Assign a baseline profile to start monitoring drift.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@elseif (($state ?? null) === 'no_snapshot')
|
@elseif ($summary)
|
||||||
<div class="flex items-start gap-3 rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
|
|
||||||
<x-heroicon-o-camera class="mt-0.5 h-5 w-5 shrink-0 text-warning-500 dark:text-warning-400" />
|
|
||||||
<div>
|
|
||||||
<div class="text-sm font-medium text-warning-900 dark:text-warning-100">Current Baseline Unavailable</div>
|
|
||||||
<div class="mt-0.5 text-sm text-warning-800 dark:text-warning-200">{{ $message }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
{{-- Profile + last compared --}}
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<div class="text-gray-600 dark:text-gray-300">
|
<div class="text-gray-600 dark:text-gray-300">
|
||||||
Baseline: <span class="font-medium text-gray-950 dark:text-white">{{ $profileName }}</span>
|
Baseline: <span class="font-medium text-gray-950 dark:text-white">{{ $profileName }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($lastComparedAt)
|
@if ($lastComparedAt)
|
||||||
<div class="text-gray-500 dark:text-gray-400">{{ $lastComparedAt }}</div>
|
<div class="text-gray-500 dark:text-gray-400">{{ $lastComparedAt }}</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Findings summary --}}
|
<div class="{{ $cardClasses }}">
|
||||||
@if ($findingsCount > 0)
|
<div class="flex items-start gap-3">
|
||||||
{{-- Critical banner (inline) --}}
|
<x-filament::icon :icon="$summaryIcon" class="{{ $iconClasses }}" />
|
||||||
@if ($highCount > 0)
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-danger-300 bg-danger-50 px-3 py-2 dark:border-danger-700 dark:bg-danger-950/50">
|
|
||||||
<x-heroicon-s-exclamation-triangle class="h-4 w-4 shrink-0 text-danger-600 dark:text-danger-400" />
|
|
||||||
<span class="text-sm font-medium text-danger-800 dark:text-danger-200">
|
|
||||||
{{ $highCount }} high-severity {{ Str::plural('finding', $highCount) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="min-w-0 flex-1 space-y-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<x-filament::badge color="danger" size="sm">
|
<x-filament::badge :color="$summaryTone" size="sm">
|
||||||
{{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
|
{{ $summaryLabel }}
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
@if ($mediumCount > 0)
|
|
||||||
<x-filament::badge color="warning" size="sm">
|
|
||||||
{{ $mediumCount }} medium
|
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($lowCount > 0)
|
@if ($findingsCount > 0)
|
||||||
<x-filament::badge color="gray" size="sm">
|
<x-filament::badge color="danger" size="sm">
|
||||||
{{ $lowCount }} low
|
{{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if ($highSeverityCount > 0)
|
||||||
|
<x-filament::badge color="danger" size="sm">
|
||||||
|
{{ $highSeverityCount }} high severity
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold {{ $textClasses }}">
|
||||||
|
{{ $summary['headline'] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($summary['supportingMessage'] ?? null))
|
||||||
|
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
{{ $summary['supportingMessage'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
@if (filled($nextActionUrl))
|
||||||
|
<x-filament::link :href="$nextActionUrl" size="sm" class="font-medium">
|
||||||
|
{{ $nextAction['label'] }}
|
||||||
|
</x-filament::link>
|
||||||
|
@elseif (filled($nextAction['label'] ?? null))
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $nextAction['label'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@else
|
</div>
|
||||||
<div class="flex items-center gap-2 rounded-lg bg-success-50 px-3 py-2 dark:bg-success-950/50">
|
|
||||||
<x-heroicon-o-check-circle class="h-4 w-4 shrink-0 text-success-600 dark:text-success-400" />
|
|
||||||
<span class="text-sm font-medium text-success-700 dark:text-success-300">No open drift — baseline compliant</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|||||||
@ -3,53 +3,58 @@
|
|||||||
wire:poll.{{ $pollingInterval }}
|
wire:poll.{{ $pollingInterval }}
|
||||||
@endif
|
@endif
|
||||||
>
|
>
|
||||||
<x-filament::section heading="Needs Attention">
|
<x-filament::section heading="Needs Attention">
|
||||||
|
@if (count($items) === 0)
|
||||||
@if (count($items) === 0)
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Everything looks healthy right now.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@foreach ($healthyChecks as $check)
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
<div class="flex items-start gap-3">
|
Current dashboard signals look trustworthy.
|
||||||
<x-filament::icon
|
</div>
|
||||||
icon="heroicon-m-check-circle"
|
|
||||||
class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="text-sm font-medium text-gray-950 dark:text-white">{{ $check['title'] }}</div>
|
@foreach ($healthyChecks as $check)
|
||||||
<div class="mt-0.5 text-sm text-gray-600 dark:text-gray-300">{{ $check['body'] }}</div>
|
<div class="flex items-start gap-3 rounded-lg bg-gray-50 p-4 dark:bg-white/5">
|
||||||
|
<x-filament::icon
|
||||||
|
icon="heroicon-m-check-circle"
|
||||||
|
class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="mt-1">
|
<div class="flex-1">
|
||||||
<x-filament::link :href="$check['url']" size="sm">
|
<div class="text-sm font-medium text-gray-950 dark:text-white">{{ $check['title'] }}</div>
|
||||||
{{ $check['linkLabel'] }}
|
<div class="mt-0.5 text-sm text-gray-600 dark:text-gray-300">{{ $check['body'] }}</div>
|
||||||
</x-filament::link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach ($items as $item)
|
||||||
|
<div class="rounded-lg bg-gray-50 p-4 dark:bg-white/5">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
|
||||||
|
|
||||||
|
@if (filled($item['supportingMessage'] ?? null))
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $item['supportingMessage'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($item['nextStep'] ?? null))
|
||||||
|
<div class="mt-2 text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $item['nextStep'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament::badge :color="$item['badgeColor']" size="sm">
|
||||||
|
{{ $item['badge'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
@endif
|
||||||
@else
|
</x-filament::section>
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
@foreach ($items as $item)
|
|
||||||
<a
|
|
||||||
href="{{ $item['url'] }}"
|
|
||||||
class="rounded-lg bg-gray-50 p-4 text-left transition hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
|
|
||||||
<x-filament::badge :color="$item['badgeColor']" size="sm">
|
|
||||||
{{ $item['badge'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</x-filament::section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,53 +1,55 @@
|
|||||||
@php
|
@php
|
||||||
/** @var bool $shouldShow */
|
/** @var array<string, mixed>|null $summaryAssessment */
|
||||||
/** @var ?string $runUrl */
|
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
|
||||||
/** @var ?string $state */
|
$tone = (string) ($summary['tone'] ?? 'warning');
|
||||||
/** @var ?string $message */
|
$headline = (string) ($summary['headline'] ?? 'Baseline compare needs review.');
|
||||||
/** @var ?string $coverageStatus */
|
$supportingMessage = $summary['supportingMessage'] ?? null;
|
||||||
/** @var ?string $fidelity */
|
$nextAction = is_array($summary['nextAction'] ?? null) ? $summary['nextAction'] : ['label' => 'Review compare detail', 'target' => 'none'];
|
||||||
/** @var int $uncoveredTypesCount */
|
|
||||||
/** @var list<string> $uncoveredTypes */
|
|
||||||
|
|
||||||
$coverageHasWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true);
|
[$wrapperClasses, $textClasses] = match ($tone) {
|
||||||
|
'danger' => [
|
||||||
|
'rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/40',
|
||||||
|
'text-danger-900 dark:text-danger-100',
|
||||||
|
],
|
||||||
|
'info' => [
|
||||||
|
'rounded-lg border border-info-300 bg-info-50 p-4 dark:border-info-700 dark:bg-info-950/40',
|
||||||
|
'text-info-900 dark:text-info-100',
|
||||||
|
],
|
||||||
|
'gray' => [
|
||||||
|
'rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-white/5',
|
||||||
|
'text-gray-900 dark:text-white',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40',
|
||||||
|
'text-warning-900 dark:text-warning-100',
|
||||||
|
],
|
||||||
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@if ($shouldShow && ($coverageHasWarnings || ($state ?? null) === 'no_snapshot'))
|
@if ($shouldShow && $summary)
|
||||||
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 text-warning-900 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100">
|
<div class="{{ $wrapperClasses }}">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-sm font-semibold">
|
<div class="text-sm font-semibold {{ $textClasses }}">
|
||||||
@if (($state ?? null) === 'no_snapshot')
|
{{ $headline }}
|
||||||
Current baseline unavailable
|
|
||||||
@else
|
|
||||||
Baseline compare coverage warnings
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
@if (($state ?? null) === 'no_snapshot')
|
|
||||||
{{ $message }}
|
|
||||||
@elseif (($coverageStatus ?? null) === 'unproven')
|
|
||||||
Coverage proof was missing or unreadable for the last baseline comparison, so findings were suppressed for safety.
|
|
||||||
@else
|
|
||||||
The last baseline comparison had incomplete coverage for {{ (int) $uncoveredTypesCount }} policy {{ Str::plural('type', (int) $uncoveredTypesCount) }}. Findings may be incomplete.
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (filled($fidelity))
|
|
||||||
<span class="ml-1 text-xs text-warning-800 dark:text-warning-300">Fidelity: {{ Str::title($fidelity) }}</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (! empty($uncoveredTypes))
|
@if (filled($supportingMessage))
|
||||||
<div class="mt-1 text-xs">
|
<div class="text-sm">
|
||||||
Uncovered: {{ implode(', ', array_slice($uncoveredTypes, 0, 6)) }}@if (count($uncoveredTypes) > 6)…@endif
|
{{ $supportingMessage }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (($state ?? null) !== 'no_snapshot' && filled($runUrl))
|
@if (filled($nextActionUrl))
|
||||||
<div class="mt-2">
|
<div class="mt-1">
|
||||||
<a class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400" href="{{ $runUrl }}">
|
<a class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400" href="{{ $nextActionUrl }}">
|
||||||
View run
|
{{ $nextAction['label'] }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@elseif (filled($nextAction['label'] ?? null))
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide">
|
||||||
|
{{ $nextAction['label'] }}
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
36
specs/165-baseline-summary-trust/checklists/requirements.md
Normal file
36
specs/165-baseline-summary-trust/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-26
|
||||||
|
**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 completed in one pass.
|
||||||
|
- No clarification markers were needed; the supplied feature description was specific enough to define scope, risks, and measurable outcomes.
|
||||||
|
- The spec stays focused on summary-truth propagation and explicitly excludes backend compare-engine or persistence redesign.
|
||||||
@ -0,0 +1,225 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Baseline Summary Surface Contract
|
||||||
|
version: 1.0.0
|
||||||
|
description: >-
|
||||||
|
Internal contract for baseline compare summary semantics across tenant dashboard,
|
||||||
|
Baseline Compare landing, findings-adjacent warning surfaces, and canonical run-detail alignment.
|
||||||
|
servers:
|
||||||
|
- url: /
|
||||||
|
paths:
|
||||||
|
/admin:
|
||||||
|
get:
|
||||||
|
summary: Tenant dashboard baseline summary contract
|
||||||
|
description: >-
|
||||||
|
HTML tenant dashboard surface whose embedded baseline summary semantics must obey the shared summary assessment.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Dashboard rendered with an embedded baseline summary assessment
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantDashboardBaselineSummary'
|
||||||
|
/admin/baseline-compare:
|
||||||
|
get:
|
||||||
|
summary: Baseline Compare landing summary contract
|
||||||
|
description: >-
|
||||||
|
Landing surface for the latest baseline compare result. The primary summary must not exceed the trust carried by the underlying explanation.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Landing page rendered with primary summary assessment and diagnostics links
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/BaselineLandingSummary'
|
||||||
|
/admin/findings:
|
||||||
|
get:
|
||||||
|
summary: Findings-adjacent baseline warning contract
|
||||||
|
description: >-
|
||||||
|
Findings context that may display a coverage or evidence caution banner derived from the same summary assessment.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Findings page rendered with optional baseline summary caution
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/BaselineBannerSummary'
|
||||||
|
/admin/operations/{run}:
|
||||||
|
get:
|
||||||
|
summary: Canonical baseline compare run-detail reference contract
|
||||||
|
description: >-
|
||||||
|
Canonical run detail remains the deepest truth surface. Compact summaries may not be more optimistic than this contract.
|
||||||
|
parameters:
|
||||||
|
- name: run
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Run detail rendered with baseline compare truth semantics
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/BaselineRunDetailReference'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
SummaryStateFamily:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- positive
|
||||||
|
- caution
|
||||||
|
- stale
|
||||||
|
- action_required
|
||||||
|
- unavailable
|
||||||
|
- in_progress
|
||||||
|
TrustworthinessLevel:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- trustworthy
|
||||||
|
- limited_confidence
|
||||||
|
- diagnostic_only
|
||||||
|
- unusable
|
||||||
|
EvaluationResult:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
`failed_result` represents a compare execution that completed without a usable decision-grade artifact
|
||||||
|
and therefore must map to an investigation-oriented `action_required` summary state rather than
|
||||||
|
`unavailable`.
|
||||||
|
enum:
|
||||||
|
- full_result
|
||||||
|
- no_result
|
||||||
|
- incomplete_result
|
||||||
|
- failed_result
|
||||||
|
- suppressed_result
|
||||||
|
- unavailable
|
||||||
|
EvidenceImpact:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- none
|
||||||
|
- coverage_warning
|
||||||
|
- evidence_gap
|
||||||
|
- stale_result
|
||||||
|
- suppressed_output
|
||||||
|
- unavailable
|
||||||
|
NextAction:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
- target
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
target:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- landing
|
||||||
|
- findings
|
||||||
|
- run
|
||||||
|
- none
|
||||||
|
BaselineSummaryAssessment:
|
||||||
|
type: object
|
||||||
|
description: >-
|
||||||
|
Shared compact-summary contract. If `evaluationResult` is `failed_result`, `stateFamily` must be
|
||||||
|
`action_required` and `nextAction.target` must not be `none`.
|
||||||
|
required:
|
||||||
|
- stateFamily
|
||||||
|
- headline
|
||||||
|
- positiveClaimAllowed
|
||||||
|
- trustworthinessLevel
|
||||||
|
- evaluationResult
|
||||||
|
- evidenceImpact
|
||||||
|
- nextAction
|
||||||
|
properties:
|
||||||
|
stateFamily:
|
||||||
|
$ref: '#/components/schemas/SummaryStateFamily'
|
||||||
|
headline:
|
||||||
|
type: string
|
||||||
|
supportingMessage:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
tone:
|
||||||
|
type: string
|
||||||
|
positiveClaimAllowed:
|
||||||
|
type: boolean
|
||||||
|
trustworthinessLevel:
|
||||||
|
$ref: '#/components/schemas/TrustworthinessLevel'
|
||||||
|
evaluationResult:
|
||||||
|
$ref: '#/components/schemas/EvaluationResult'
|
||||||
|
evidenceImpact:
|
||||||
|
$ref: '#/components/schemas/EvidenceImpact'
|
||||||
|
findingsVisibleCount:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
highSeverityCount:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
reasonCode:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
lastComparedLabel:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
nextAction:
|
||||||
|
$ref: '#/components/schemas/NextAction'
|
||||||
|
TenantDashboardBaselineSummary:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- widget
|
||||||
|
- needsAttention
|
||||||
|
properties:
|
||||||
|
widget:
|
||||||
|
$ref: '#/components/schemas/BaselineSummaryAssessment'
|
||||||
|
needsAttention:
|
||||||
|
$ref: '#/components/schemas/BaselineSummaryAssessment'
|
||||||
|
kpiCards:
|
||||||
|
type: array
|
||||||
|
description: Quantitative indicators only; not claim-bearing semantics.
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
- value
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
BaselineLandingSummary:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- primarySummary
|
||||||
|
properties:
|
||||||
|
primarySummary:
|
||||||
|
$ref: '#/components/schemas/BaselineSummaryAssessment'
|
||||||
|
diagnosticsAvailable:
|
||||||
|
type: boolean
|
||||||
|
runLinkAvailable:
|
||||||
|
type: boolean
|
||||||
|
findingsLinkAvailable:
|
||||||
|
type: boolean
|
||||||
|
BaselineBannerSummary:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- shouldShow
|
||||||
|
properties:
|
||||||
|
shouldShow:
|
||||||
|
type: boolean
|
||||||
|
bannerSummary:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/BaselineSummaryAssessment'
|
||||||
|
nullable: true
|
||||||
|
BaselineRunDetailReference:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- primarySummary
|
||||||
|
properties:
|
||||||
|
primarySummary:
|
||||||
|
$ref: '#/components/schemas/BaselineSummaryAssessment'
|
||||||
|
semanticCeiling:
|
||||||
|
type: boolean
|
||||||
|
description: Always true for the canonical detail; compact surfaces may not exceed this confidence.
|
||||||
|
x-tenantpilot-notes:
|
||||||
|
- These routes render HTML in practice; the schema models the internal summary payload that their views must honor.
|
||||||
|
- No new HTTP endpoints are introduced by this feature.
|
||||||
183
specs/165-baseline-summary-trust/data-model.md
Normal file
183
specs/165-baseline-summary-trust/data-model.md
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
# Data Model: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature introduces no new persistence model. The data model is a derived view-model contract that lifts existing compare truth, explanation, and evidence signals into compact summary surfaces.
|
||||||
|
|
||||||
|
## Derived Entities
|
||||||
|
|
||||||
|
### 1. BaselineCompareStats
|
||||||
|
|
||||||
|
- Type: existing immutable support DTO
|
||||||
|
- Source: `app/Support/Baselines/BaselineCompareStats`
|
||||||
|
- Responsibility: canonical aggregate of baseline assignment state, latest compare run state, findings totals, severity counts, coverage status, reason code, evidence-gap totals, diagnostics, and related run identity
|
||||||
|
- Relevant fields for this feature:
|
||||||
|
- `state`
|
||||||
|
- `message`
|
||||||
|
- `reasonCode`
|
||||||
|
- `reasonMessage`
|
||||||
|
- `operationRunId`
|
||||||
|
- `findingsCount`
|
||||||
|
- `severityCounts`
|
||||||
|
- `coverageStatus`
|
||||||
|
- `uncoveredTypesCount`
|
||||||
|
- `uncoveredTypes`
|
||||||
|
- `fidelity`
|
||||||
|
- `evidenceGapsCount`
|
||||||
|
- `evidenceGapDetails`
|
||||||
|
- `lastComparedHuman`
|
||||||
|
- `lastComparedIso`
|
||||||
|
|
||||||
|
### 2. OperatorExplanationPattern
|
||||||
|
|
||||||
|
- Type: existing derived explanation object
|
||||||
|
- Source: `BaselineCompareExplanationRegistry::forStats()` via `BaselineCompareStats::operatorExplanation()`
|
||||||
|
- Responsibility: translates stats into explanation family, evaluation result, trustworthiness, reliability statement, coverage statement, dominant cause, and next action
|
||||||
|
- Relevant fields for this feature:
|
||||||
|
- `family`
|
||||||
|
- `headline`
|
||||||
|
- `executionOutcome`
|
||||||
|
- `evaluationResult`
|
||||||
|
- `trustworthinessLevel`
|
||||||
|
- `reliabilityStatement`
|
||||||
|
- `coverageStatement`
|
||||||
|
- `dominantCauseCode`
|
||||||
|
- `dominantCauseLabel`
|
||||||
|
- `nextActionCategory`
|
||||||
|
- `nextActionText`
|
||||||
|
- `countDescriptors`
|
||||||
|
|
||||||
|
### 3. BaselineSummaryAssessment
|
||||||
|
|
||||||
|
- Type: new derived support-layer contract
|
||||||
|
- Persistence: none
|
||||||
|
- Responsibility: one compact summary-safe interpretation of the current baseline compare posture that can be rendered consistently across dashboard, banner, and landing summary surfaces
|
||||||
|
- Documentation note: `not-ready` in the spec or task wording is not an extra enum value; it must resolve to either `in_progress` or `unavailable` in the formal contract.
|
||||||
|
|
||||||
|
#### Proposed Fields
|
||||||
|
|
||||||
|
- `stateFamily`: one of `positive`, `caution`, `stale`, `action_required`, `unavailable`, `in_progress`
|
||||||
|
- `headline`: strongest safe primary statement for the current compare posture
|
||||||
|
- `supportingMessage`: short secondary explanation, optional
|
||||||
|
- `tone`: centralized tone or badge domain for rendering emphasis
|
||||||
|
- `positiveClaimAllowed`: boolean guard used to block compliant or no-drift wording
|
||||||
|
- `trustworthinessLevel`: copied or normalized from operator explanation
|
||||||
|
- `evaluationResult`: copied or normalized from operator explanation, including `failed_result` when compare execution failed or produced no decision-grade artifact
|
||||||
|
- `evidenceImpact`: enum-like derived label such as `none`, `coverage_warning`, `evidence_gap`, `stale_result`, `suppressed_output`, `unavailable`
|
||||||
|
- `findingsVisibleCount`: numeric descriptor, not itself the verdict
|
||||||
|
- `highSeverityCount`: numeric descriptor for compact severity emphasis
|
||||||
|
- `nextAction`: structured object with:
|
||||||
|
- `label`: concise action cue
|
||||||
|
- `target`: one of `landing`, `findings`, `run`, `none`
|
||||||
|
- `lastComparedLabel`: relative-time summary where applicable
|
||||||
|
- `reasonCode`: current dominant reason code when one exists
|
||||||
|
|
||||||
|
#### Validation Rules
|
||||||
|
|
||||||
|
- `positiveClaimAllowed` may be `true` only when:
|
||||||
|
- a usable compare result exists
|
||||||
|
- trustworthiness is decision-grade
|
||||||
|
- evaluation result is not incomplete, suppressed, failed, or unavailable
|
||||||
|
- there is no material evidence or coverage limitation undermining the claim
|
||||||
|
- `stateFamily = positive` requires `positiveClaimAllowed = true`
|
||||||
|
- `stateFamily = caution` requires a usable but limited result
|
||||||
|
- `stateFamily = stale` requires a usable but no-longer-fresh result whose age materially limits the safety of an all-clear claim
|
||||||
|
- `stateFamily = action_required` requires confirmed drift, failed compare execution, or another follow-up-critical state
|
||||||
|
- `stateFamily = unavailable` requires no usable result, no snapshot, compare never run, or equivalent pre-execution unavailability
|
||||||
|
- `headline` must never be semantically stronger than the current explanation family and trustworthiness combination
|
||||||
|
|
||||||
|
### 4. Surface Consumption Profile
|
||||||
|
|
||||||
|
- Type: new derived rendering hint, optionally implicit rather than a dedicated class
|
||||||
|
- Persistence: none
|
||||||
|
- Responsibility: allows the same summary assessment to render at different compactness levels without changing its semantic meaning
|
||||||
|
|
||||||
|
#### Candidate Variants
|
||||||
|
|
||||||
|
- `dashboard_widget`
|
||||||
|
- `needs_attention`
|
||||||
|
- `coverage_banner`
|
||||||
|
- `landing_summary`
|
||||||
|
- `canonical_detail_reference`
|
||||||
|
|
||||||
|
#### Expected Behavior
|
||||||
|
|
||||||
|
- All variants share the same `stateFamily`, `headline`, and `positiveClaimAllowed`
|
||||||
|
- Variants may differ in verbosity, badge count, and which next-action link is most prominent
|
||||||
|
- No variant may upgrade a cautionary or unavailable assessment into a positive assessment
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- `BaselineCompareStats` -> `OperatorExplanationPattern`
|
||||||
|
- `BaselineCompareStats` + `OperatorExplanationPattern` -> `BaselineSummaryAssessment`
|
||||||
|
- `BaselineSummaryAssessment` + `Surface Consumption Profile` -> rendered widget, banner, or landing summary output
|
||||||
|
- Canonical run detail remains the deeper truth surface that validates the same underlying explanation and reason semantics
|
||||||
|
|
||||||
|
## State Families
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Meaning: no confirmed drift and the result is safe to treat as trustworthy
|
||||||
|
- Allowed only when positive-claim guard passes
|
||||||
|
- Example outcomes:
|
||||||
|
- trustworthy no-result with no material evidence limitation
|
||||||
|
|
||||||
|
### Caution
|
||||||
|
|
||||||
|
- Meaning: no all-clear claim is allowed because the result is limited, incomplete, or partially reliable
|
||||||
|
- Typical drivers:
|
||||||
|
- evidence gaps
|
||||||
|
- coverage warnings
|
||||||
|
- suppressed output
|
||||||
|
- limited-confidence explanation family
|
||||||
|
|
||||||
|
### Stale
|
||||||
|
|
||||||
|
- Meaning: a previously usable compare result exists, but its freshness is no longer strong enough to support a current-state all-clear claim
|
||||||
|
- Typical drivers:
|
||||||
|
- aged compare history beyond the current freshness threshold
|
||||||
|
- no newer compare since a material tenant or baseline change
|
||||||
|
|
||||||
|
### Action Required
|
||||||
|
|
||||||
|
- Meaning: confirmed drift, failed compare requiring review, or another immediately actionable posture
|
||||||
|
- Typical drivers:
|
||||||
|
- visible open drift findings
|
||||||
|
- failed compare with explicit investigation path
|
||||||
|
|
||||||
|
### Unavailable
|
||||||
|
|
||||||
|
- Meaning: no usable compare result currently exists
|
||||||
|
- Typical drivers:
|
||||||
|
- no assignment
|
||||||
|
- no snapshot
|
||||||
|
- compare never run
|
||||||
|
- blocked prerequisite
|
||||||
|
|
||||||
|
### In Progress
|
||||||
|
|
||||||
|
- Meaning: compare is queued or running and current numbers are diagnostic only
|
||||||
|
- Typical drivers:
|
||||||
|
- active compare operation run
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
The summary-state family is derived, not persisted. Expected transition patterns:
|
||||||
|
|
||||||
|
- `unavailable` -> `in_progress` when a compare starts
|
||||||
|
- `in_progress` -> `positive` when the compare completes with trustworthy no-result semantics
|
||||||
|
- `in_progress` -> `caution` when the compare completes with limited-confidence or suppressed-result semantics
|
||||||
|
- `positive` or `caution` -> `stale` when freshness decays below the decision-grade threshold without a newer compare
|
||||||
|
- `stale` -> `in_progress` when a refresh compare starts
|
||||||
|
- `in_progress` -> `action_required` when the compare records open drift findings or a failure state demanding review
|
||||||
|
- `positive` -> `caution` if later evidence or coverage limits undercut trustworthiness
|
||||||
|
- `positive` or `caution` -> `unavailable` when no consumable snapshot or no usable result remains available
|
||||||
|
|
||||||
|
## Test-Critical Invariants
|
||||||
|
|
||||||
|
- `0 findings` must not force `stateFamily = positive`
|
||||||
|
- `positiveClaimAllowed = false` must block `Compliant`, `No drift`, `No open drift`, and equivalent copy on all compact surfaces
|
||||||
|
- Evidence gaps must be able to move a surface from `positive` to `caution` even when `coverageStatus = ok`
|
||||||
|
- Stale compare history must not collapse into `unavailable` or `positive`; it needs its own compact summary semantics
|
||||||
|
- Dashboard and landing surfaces consuming the same assessment must not disagree on the primary state family
|
||||||
|
- Compact surfaces may omit details, but not semantic qualifiers
|
||||||
277
specs/165-baseline-summary-trust/plan.md
Normal file
277
specs/165-baseline-summary-trust/plan.md
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
# Implementation Plan: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
|
||||||
|
|
||||||
|
**Branch**: `165-baseline-summary-trust` | **Date**: 2026-03-26 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/165-baseline-summary-trust/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/165-baseline-summary-trust/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Harden all in-scope baseline and drift summary surfaces so that no compact widget, KPI-adjacent summary, banner, or landing headline can imply `Compliant`, `No drift`, or an equivalent all-clear unless the underlying compare result is genuinely trustworthy. The implementation will introduce a shared baseline summary-state contract derived from the existing baseline compare truth and explanation layers, replace findings-count shortcuts on the tenant dashboard, propagate evidence-gap and coverage limitations into summary claims, keep the landing surface and run drilldown semantically aligned, and lock the behavior down with focused Pest and Livewire coverage.
|
||||||
|
|
||||||
|
Key approach: reuse the current baseline domain seams already present in `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, reason translation, and badge semantics; add one reusable summary assessment layer for compact surfaces; preserve the existing `Compare now` action, routes, and DB-only render behavior; and avoid any model, enum, or schema changes.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
|
||||||
|
**Storage**: PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned
|
||||||
|
**Testing**: Pest feature tests, Livewire component tests, dashboard DB-only render regression, all executed through Sail
|
||||||
|
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
|
||||||
|
**Project Type**: Laravel monolith web application
|
||||||
|
**Performance Goals**: Keep dashboard and landing renders DB-only, preserve existing lazy-widget behavior, avoid new outbound HTTP or background dispatch during render, and keep summary claims understandable within one short scan of each surface
|
||||||
|
**Constraints**: No new database tables, no new outcome enums, no compare-engine rewrite, no route or RBAC drift, no new global assets, no dashboard summary more optimistic than landing or run detail, and no new ad hoc status-color language
|
||||||
|
**Scale/Scope**: Four primary summary surface families, one shared support-layer contract, one tenant landing page, one canonical drilldown alignment path, and focused regression coverage across trustworthy, limited, failed, missing, and evidence-gap-affected compare scenarios
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Inventory-first | Pass | No inventory, snapshot, or evidence ownership semantics change; the work is presentation hardening only |
|
||||||
|
| Read/write separation | Pass | No new mutation path is introduced; the feature remains read-only except for the already-existing guarded `Compare now` action |
|
||||||
|
| Graph contract path | Pass | No new Graph call path, contract-registry entry, or render-time network access is introduced |
|
||||||
|
| Deterministic capabilities | Pass | No new capability derivation; existing capability and tenant-view checks remain authoritative |
|
||||||
|
| RBAC-UX planes and 404 vs 403 | Pass | All covered surfaces stay in the tenant/admin plane except the existing canonical run drilldown, which keeps current tenant-safe access rules |
|
||||||
|
| Workspace isolation | Pass | No workspace-context broadening; tenant summary surfaces still require an established workspace context |
|
||||||
|
| Tenant isolation | Pass | Covered surfaces remain tenant-scoped, and canonical run drilldown remains entitlement-checked before revealing tenant-linked evidence |
|
||||||
|
| Destructive confirmation | Pass | No new destructive action; existing `Compare now` already uses confirmation and capability gating |
|
||||||
|
| Global search safety | Pass | No global-search behavior or searchable resource configuration changes are part of this feature |
|
||||||
|
| Run observability | Pass | Existing baseline compare `OperationRun` behavior remains unchanged; the feature only reads and interprets current run evidence |
|
||||||
|
| Ops-UX 3-surface feedback | Pass | No new toasts, progress surfaces, or terminal notifications are introduced |
|
||||||
|
| Ops-UX lifecycle ownership | Pass | `OperationRun.status` and `OperationRun.outcome` remain service-owned and untouched by this feature |
|
||||||
|
| Ops-UX summary counts | Pass | Existing `summary_counts` rules stay unchanged; the feature consumes result meaning rather than redefining counts |
|
||||||
|
| Ops-UX guards | Pass | Existing lifecycle guards remain intact; new tests will focus on summary truth and cross-surface consistency |
|
||||||
|
| Data minimization | Pass | No new secrets, raw Graph payloads, or low-level diagnostics are elevated into default-visible summaries |
|
||||||
|
| Badge semantics (BADGE-001) | Pass | Status tones and badges must continue to come from central badge or shared primitive semantics rather than page-local green or warning shortcuts |
|
||||||
|
| Filament-native UI (UI-FIL-001) | Pass | Widgets, banners, and landing summaries continue to use Filament sections, badges, links, and shared primitives rather than bespoke status components |
|
||||||
|
| UI naming (UI-NAMING-001) | Pass | Operator-facing copy remains domain-first and must avoid false-calming phrases when the result is not decision-grade |
|
||||||
|
| Operator surfaces (OPSURF-001) | Pass | The feature explicitly strengthens operator-first meaning by carrying governance result, evidence completeness, and next step into compact surfaces |
|
||||||
|
| Filament Action Surface Contract | Pass | Existing action inventory stays stable; only summary semantics and wording change |
|
||||||
|
| Filament UX-001 | Pass with documented variance | The landing page remains a custom enterprise layout rather than a stock infolist, but it still honors sectioning, centralized badges, and operator-first hierarchy |
|
||||||
|
| Filament v5 / Livewire v4 compliance | Pass | All work stays within the current Filament v5 and Livewire v4 stack |
|
||||||
|
| Provider registration location | Pass | No panel or provider changes are required; Laravel 11+ provider registration remains in `bootstrap/providers.php` |
|
||||||
|
| Global-search hard rule | Pass | No globally searchable resource is added or modified; no Edit/View-page requirement changes are triggered |
|
||||||
|
| Asset strategy | Pass | No new Filament assets are planned; deployment expectations for `php artisan filament:assets` remain unchanged because no asset registration changes are introduced |
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/165-baseline-summary-trust/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Use one shared summary assessment derived from `BaselineCompareStats` and `operatorExplanation()` instead of findings-count-only widget logic.
|
||||||
|
- Treat the current `BaselineCompareNow` success pill and the `NeedsAttention` healthy state as the highest-risk false-calm surfaces and harden them first.
|
||||||
|
- Propagate evidence gaps into summary semantics even when uncovered-types coverage warnings are absent.
|
||||||
|
- Keep KPI cards quantitative only; they may link to deeper surfaces but must not become semantic all-clear claims.
|
||||||
|
- Extend existing landing, widget, and baseline-truth tests rather than creating a separate UI harness.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/165-baseline-summary-trust/`:
|
||||||
|
|
||||||
|
- `data-model.md`: derived summary-state entities, fields, and state-family rules
|
||||||
|
- `contracts/baseline-summary-surface.openapi.yaml`: internal surface-contract schema for compact baseline compare claims across dashboard, landing, banner, and run drilldown
|
||||||
|
- `quickstart.md`: focused verification workflow for manual and automated validation
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- No schema migration is required; the design uses existing baseline compare stats, reason translation, operator explanation, findings, and operation-run evidence.
|
||||||
|
- The primary implementation seam is a new shared support-layer summary assessment in `app/Support/Baselines`, consumed by dashboard widgets, the landing page, and any summary-adjacent banner or headline.
|
||||||
|
- The existing `BaselineCompareStats::forWidget()` shortcut is too lossy for trust propagation, so covered summary surfaces must consume either the richer tenant stats or a derived contract built from them.
|
||||||
|
- `BaselineCompareNow` and `NeedsAttention` must stop deriving healthy or compliant claims from zero findings alone.
|
||||||
|
- The coverage banner must consider evidence gaps as summary-limiting signals, not only uncovered policy types and missing snapshots.
|
||||||
|
- Canonical run detail remains the deepest truth surface and becomes the semantic ceiling: compact surfaces may be equally cautious or more cautious, never more optimistic.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/165-baseline-summary-trust/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── baseline-summary-surface.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ └── BaselineCompareLanding.php
|
||||||
|
│ └── Widgets/
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ │ ├── BaselineCompareNow.php
|
||||||
|
│ │ ├── DashboardKpis.php
|
||||||
|
│ │ └── NeedsAttention.php
|
||||||
|
│ └── Tenant/
|
||||||
|
│ └── BaselineCompareCoverageBanner.php
|
||||||
|
├── Support/
|
||||||
|
│ ├── Baselines/
|
||||||
|
│ │ ├── BaselineCompareStats.php
|
||||||
|
│ │ ├── BaselineCompareExplanationRegistry.php
|
||||||
|
│ │ ├── BaselineCompareEvidenceGapDetails.php
|
||||||
|
│ │ └── BaselineCompareReasonCode.php
|
||||||
|
│ ├── Badges/
|
||||||
|
│ │ ├── BadgeCatalog.php
|
||||||
|
│ │ └── BadgeRenderer.php
|
||||||
|
│ └── ReasonTranslation/
|
||||||
|
│ └── ReasonTranslator.php
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
├── pages/
|
||||||
|
│ └── baseline-compare-landing.blade.php
|
||||||
|
└── widgets/
|
||||||
|
├── dashboard/
|
||||||
|
│ ├── baseline-compare-now.blade.php
|
||||||
|
│ └── needs-attention.blade.php
|
||||||
|
└── tenant/
|
||||||
|
└── baseline-compare-coverage-banner.blade.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Baselines/
|
||||||
|
│ │ ├── BaselineCompareStatsTest.php
|
||||||
|
│ │ ├── BaselineCompareSummaryAssessmentTest.php
|
||||||
|
│ │ ├── BaselineCompareExplanationFallbackTest.php
|
||||||
|
│ │ └── BaselineCompareWhyNoFindingsReasonCodeTest.php
|
||||||
|
│ ├── ReasonTranslation/
|
||||||
|
│ │ └── ReasonTranslationExplanationTest.php
|
||||||
|
│ └── Filament/
|
||||||
|
│ ├── BaselineCompareCoverageBannerTest.php
|
||||||
|
│ ├── BaselineCompareExplanationSurfaceTest.php
|
||||||
|
│ ├── BaselineCompareLandingWhyNoFindingsTest.php
|
||||||
|
│ ├── BaselineCompareLandingStartSurfaceTest.php
|
||||||
|
│ ├── BaselineCompareNowWidgetTest.php
|
||||||
|
│ ├── BaselineCompareSummaryConsistencyTest.php
|
||||||
|
│ ├── NeedsAttentionWidgetTest.php
|
||||||
|
│ ├── OperationRunBaselineTruthSurfaceTest.php
|
||||||
|
│ └── TenantDashboardDbOnlyTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Standard Laravel monolith. The feature is confined to the existing support-layer baseline truth objects, a small number of tenant-facing Filament widgets and pages, and focused Pest coverage. No new base directories or architectural layers are required beyond a shared compact-summary support seam inside `app/Support/Baselines`.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Establish One Shared Summary Truth Contract
|
||||||
|
|
||||||
|
**Goal**: Derive one reusable summary-state assessment from existing baseline compare truth and explanation layers so widgets and landing summaries stop improvising their own semantics.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `app/Support/Baselines/BaselineCompareStats.php` | Refactor or extend the compact-summary seam so covered surfaces can consume trustworthiness, evidence completeness, reason semantics, and result availability instead of findings counts only |
|
||||||
|
| A.2 | `app/Support/Baselines/BaselineCompareExplanationRegistry.php` and an adjacent new support type | Introduce a shared summary assessment or presenter that maps stats plus explanation into summary state family, safe headline, tone, and next step |
|
||||||
|
| A.3 | `app/Support/Baselines/BaselineCompareReasonCode.php` and reason translation seams if needed | Ensure positive claim eligibility and limited-confidence semantics stay aligned with current explanation-family and trustworthiness rules |
|
||||||
|
| A.4 | Shared badge or UI support helpers if needed | Keep badge or tone selection centralized and avoid page-local success shortcuts |
|
||||||
|
|
||||||
|
### Phase B — Harden Tenant Dashboard Summary Surfaces
|
||||||
|
|
||||||
|
**Goal**: Remove the most dangerous false-calm claims from the tenant dashboard without breaking lazy loading or DB-only behavior, while keeping stale, failed, missing, in-progress, and unavailable compare states visibly distinct.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` | Replace the findings-only widget payload with the shared summary assessment contract |
|
||||||
|
| B.2 | `resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php` | Replace `No open drift — baseline compliant` with contract-driven positive, cautionary, stale, unavailable, in-progress, or review-oriented states |
|
||||||
|
| B.3 | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | Feed healthy-check and attention-item generation from the shared summary contract so limited, stale, in-progress, unavailable, or incomplete compare results cannot fall through to `Everything looks healthy right now.` |
|
||||||
|
| B.4 | `resources/views/filament/widgets/dashboard/needs-attention.blade.php` | Keep the widget compact while showing truthful caution and next-step language when compare evidence is limited |
|
||||||
|
| B.5 | `app/Filament/Widgets/Dashboard/DashboardKpis.php` | Verify that KPI cards remain quantitative-only and do not imply stronger semantic claims than the shared contract allows |
|
||||||
|
|
||||||
|
### Phase C — Align Landing And Banner Surfaces With The Same Claim Guard
|
||||||
|
|
||||||
|
**Goal**: Ensure the Baseline Compare landing surface and findings-adjacent banner use the same claim-strength rules as the dashboard, including distinct stale, in-progress, and unavailable result handling.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `app/Filament/Pages/BaselineCompareLanding.php` | Expose the shared summary assessment to the Blade view alongside existing explanation and diagnostics payloads |
|
||||||
|
| C.2 | `resources/views/filament/pages/baseline-compare-landing.blade.php` | Make the visible headline and zero-findings explanation obey the hardened positive-claim rules rather than findings count alone, including distinct stale, in-progress, and unavailable states |
|
||||||
|
| C.3 | `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php` | Expand the banner trigger and text so evidence gaps and limited-confidence results can influence the summary, not only uncovered types or missing snapshots |
|
||||||
|
| C.4 | `resources/views/filament/widgets/tenant/baseline-compare-coverage-banner.blade.php` | Preserve compact warning language while clearly distinguishing incomplete evidence, suppressed output, and baseline unavailability |
|
||||||
|
|
||||||
|
### Phase D — Keep Canonical Drilldown As The Semantic Ceiling
|
||||||
|
|
||||||
|
**Goal**: Preserve the operation-run detail surface as the deepest truth surface and ensure summary surfaces cannot out-claim it.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | Existing baseline compare run-detail presentation seams | Verify that compact summary wording does not become stronger than current artifact-truth and operator-explanation wording on the run detail surface |
|
||||||
|
| D.2 | Shared reason or explanation helpers if needed | Reuse the same explanation-family semantics across summary and detail instead of duplicating widget-only logic |
|
||||||
|
| D.3 | No route or action change | Keep existing dashboard, banner, and landing drilldowns to `Compare now`, `View run`, and `Open findings` intact so limited states have a clear resolution path, and keep `Needs Attention` explicitly non-navigational if it exposes no existing drilldown |
|
||||||
|
|
||||||
|
### Phase E — Regression Protection And Focused Validation
|
||||||
|
|
||||||
|
**Goal**: Lock the summary truth contract into tests, including the dashboard false-calm case that currently passes as compliant.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `tests/Feature/Filament/BaselineCompareNowWidgetTest.php` | Replace the current compliant assertion with scenario coverage for trustworthy, limited-confidence, stale, failed, in-progress, and unavailable summary states |
|
||||||
|
| E.2 | `tests/Feature/Filament/NeedsAttentionWidgetTest.php` | Cover `NeedsAttention` healthy-state fallback and evidence-gap-, stale-, in-progress-, and unavailable-driven caution on the dashboard |
|
||||||
|
| E.3 | `tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, and `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php` | Extend landing and banner assertions so zero findings plus limited evidence, stale history, or in-progress or unavailable compare state never becomes an all-clear claim |
|
||||||
|
| E.4 | `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php` and adjacent explanation tests | Add or adjust support-layer assertions around positive-claim eligibility, stale-versus-not-ready distinction, and summary-state derivation |
|
||||||
|
| E.5 | `tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php` | Preserve reason-translation trust-impact and absence-pattern semantics for compact summary claims and deeper artifact-truth surfaces |
|
||||||
|
| E.6 | `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, and `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php` | Preserve deny-as-not-found semantics, compare-now capability gating, dashboard, banner, and landing summary-to-run-detail or findings drilldown expectations, and the intentionally non-navigational `Needs Attention` behavior while summary wording changes |
|
||||||
|
| E.7 | `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php` and `tests/Feature/Filament/TenantDashboardDbOnlyTest.php` | Preserve cross-surface semantic consistency, drilldown parity, and DB-only dashboard render behavior |
|
||||||
|
| E.8 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before implementation is considered complete |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D-001 — The summary contract must originate from the truth layer, not from widget-local counts
|
||||||
|
|
||||||
|
`BaselineCompareNow` currently consumes `BaselineCompareStats::forWidget()`, which only knows counts, assignment, snapshot presence, and last compare time. That shortcut is too weak for trust propagation. The design therefore promotes a shared summary contract built from the richer compare truth and explanation seams.
|
||||||
|
|
||||||
|
### D-002 — Zero findings is a count descriptor, not a governance verdict
|
||||||
|
|
||||||
|
The existing landing explanation layer already distinguishes trustworthy no-result, suppressed output, incomplete result, unavailable result, and blocked or missing inputs. The compact summary contract must preserve that distinction instead of translating `0 findings` directly into `baseline compliant`.
|
||||||
|
|
||||||
|
### D-003 — Dashboard healthy states are part of the truth surface, not decorative filler
|
||||||
|
|
||||||
|
`NeedsAttention` currently falls back to `Everything looks healthy right now.` whenever no high-severity findings, stale compare, failure, or active runs are present. That fallback is itself a semantic claim and must be driven by the shared compare summary contract.
|
||||||
|
|
||||||
|
### D-004 — Coverage gaps and evidence gaps both qualify summary truth
|
||||||
|
|
||||||
|
The current coverage banner understands uncovered types and missing snapshots, but evidence-gap-driven incompleteness can still remain invisible. The plan therefore treats evidence gaps as first-class summary-limiting inputs even when coverage proof technically exists.
|
||||||
|
|
||||||
|
### D-005 — KPI cards stay numeric; claim-bearing surfaces carry the semantic burden
|
||||||
|
|
||||||
|
The KPI cards can remain simple quantitative indicators so long as they do not add healthy or compliant phrasing. This keeps the plan focused on the surfaces that actually communicate reassurance.
|
||||||
|
|
||||||
|
### D-006 — Stale and not-ready are separate operator states, not generic unavailability
|
||||||
|
|
||||||
|
The spec explicitly distinguishes empty, missing, failed, stale, and not-ready compare situations. The shared summary contract therefore must keep stale-history separate from the formal `in_progress` and `unavailable` cases so operators can tell whether they should rerun, wait, or inspect deeper evidence.
|
||||||
|
|
||||||
|
### D-007 — Summary hardening must preserve guardrails and drilldowns, not just wording
|
||||||
|
|
||||||
|
Because the feature changes meaning on operator-facing surfaces, it must also preserve the existing landing guard contract: deny-as-not-found for non-members, capability-gated `Compare now`, and the current drilldown paths to landing, findings, and canonical run detail.
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| Shared summary contract becomes another parallel truth model | High | Medium | Derive it directly from existing stats plus operator explanation instead of inventing a new independent state machine |
|
||||||
|
| Dashboard widgets become too noisy or verbose | Medium | Medium | Use compact state families and one primary next step rather than dumping diagnostics into widgets |
|
||||||
|
| Landing and widget wording drift apart again over time | Medium | Medium | Centralize claim eligibility and state-family mapping in the shared support layer and cover it with tests |
|
||||||
|
| Evidence gaps over-trigger warnings and hide genuinely trustworthy no-drift states | Medium | Low | Keep positive claims allowed when trustworthiness is decision-grade and no material limitation is present |
|
||||||
|
| Summary hardening accidentally introduces extra queries or render-time side effects | Medium | Low | Reuse existing DB-only stats paths, preserve lazy widgets, and keep dashboard DB-only regression coverage |
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
- Extend existing baseline compare feature and Livewire tests rather than introducing a new UI test harness.
|
||||||
|
- Add explicit scenario coverage for trustworthy no-drift, limited-confidence zero-findings, incomplete evidence, stale compare history, failed compare, in-progress states, and unavailable no-result-yet or no-snapshot states.
|
||||||
|
- Add at least one cross-surface consistency assertion ensuring a dashboard or banner summary is never more optimistic than the landing or canonical run detail for the same compare state.
|
||||||
|
- Preserve and extend existing reason-translation assertions so compact summary claims reuse the same trust-impact and absence-pattern semantics as deeper artifact-truth surfaces.
|
||||||
|
- Preserve existing compare-start and access assertions so the feature does not regress deny-as-not-found behavior, `Compare now` confirmation, capability gating, or summary-to-detail drilldown language.
|
||||||
|
- Preserve `TenantDashboardDbOnlyTest` so dashboard hardening cannot introduce outbound HTTP or background work during render.
|
||||||
|
- Run the minimum focused Pest subset through Sail for touched files and ask separately before running the full suite.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations or justified complexity exceptions were identified.
|
||||||
67
specs/165-baseline-summary-trust/quickstart.md
Normal file
67
specs/165-baseline-summary-trust/quickstart.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# Quickstart: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Verify that compact baseline and drift summary surfaces stop issuing false compliant or all-clear claims when the underlying compare result is limited, incomplete, stale, in progress, unavailable, suppressed, or otherwise not decision-grade.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
1. Start Sail and ensure the tenant panel is accessible.
|
||||||
|
2. Use a tenant with an assigned baseline profile and an active baseline snapshot.
|
||||||
|
3. Prepare representative compare scenarios using existing factories or fixtures:
|
||||||
|
- trustworthy no-drift result
|
||||||
|
- limited-confidence zero-findings result
|
||||||
|
- evidence-gap-affected result with no open findings
|
||||||
|
- stale compare history with no new confirmed drift
|
||||||
|
- failed compare result
|
||||||
|
- no compare yet, compare in progress, or no snapshot result
|
||||||
|
|
||||||
|
## Manual Verification Flow
|
||||||
|
|
||||||
|
### Scenario 1: Trustworthy no-drift result
|
||||||
|
|
||||||
|
1. Open the tenant dashboard.
|
||||||
|
2. Confirm the baseline summary widget may show a positive aligned state and still links to the deeper Baseline Compare or run-detail path.
|
||||||
|
3. Confirm the same tenant's Baseline Compare landing page shows compatible no-drift semantics and preserves its findings or run-detail drilldowns.
|
||||||
|
4. Confirm the canonical run detail for the same compare is equally confident or more detailed, never less aligned.
|
||||||
|
|
||||||
|
### Scenario 2: Limited-confidence zero-findings result
|
||||||
|
|
||||||
|
1. Open the tenant dashboard for a compare result with zero visible findings but limited confidence or suppressed output.
|
||||||
|
2. Confirm the baseline summary widget does not show compliant or all-clear wording.
|
||||||
|
3. Confirm `Needs Attention` does not fall back to a blanket healthy message and does not introduce a new drilldown path if the surface is intentionally non-navigational.
|
||||||
|
4. Open the landing page and verify the primary explanation remains cautionary while its drilldowns still resolve to the expected findings or run-detail surface.
|
||||||
|
5. Open the run detail and confirm the summary was not more optimistic than the detail surface.
|
||||||
|
|
||||||
|
### Scenario 3: Evidence gaps with no open findings
|
||||||
|
|
||||||
|
1. Open a tenant with evidence gaps recorded but no open drift findings.
|
||||||
|
2. Confirm a compact summary surface visibly signals caution or review.
|
||||||
|
3. Confirm the coverage or evidence banner appears when appropriate and offers the expected drilldown path to landing or run detail.
|
||||||
|
4. Confirm the landing page still exposes deeper evidence-gap detail and diagnostics.
|
||||||
|
|
||||||
|
### Scenario 4: Missing, stale, or unusable result
|
||||||
|
|
||||||
|
1. Verify the stale-history state stays distinct from no-result and does not render as healthy.
|
||||||
|
2. Verify the compare-in-progress state is visibly in progress rather than unavailable or healthy.
|
||||||
|
3. Verify the no-snapshot or no-compare-yet state remains unavailable rather than in progress or healthy.
|
||||||
|
4. Verify the failed-compare state gives an investigation-oriented next step.
|
||||||
|
5. Verify the existing `Compare now` action remains available only where already authorized and correctly guarded.
|
||||||
|
|
||||||
|
## Automated Verification
|
||||||
|
|
||||||
|
Run the same focused verification pack referenced by `tasks.md` through Sail:
|
||||||
|
|
||||||
|
1. `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php tests/Feature/Baselines/BaselineCompareStatsTest.php tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php`
|
||||||
|
2. `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareNowWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php`
|
||||||
|
3. `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`
|
||||||
|
4. `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php`
|
||||||
|
5. `vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||||
|
6. `vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
|
||||||
|
## Expected Outcome
|
||||||
|
|
||||||
|
- No in-scope summary surface presents compliant or equivalent all-clear copy for limited-confidence, incomplete-evidence, stale, in-progress, suppressed-result, failed, or unavailable scenarios.
|
||||||
|
- Trustworthy no-drift scenarios can still present a positive aligned state.
|
||||||
|
- Dashboard, landing, banner, and canonical detail surfaces remain semantically aligned.
|
||||||
|
- Existing compare action behavior, lazy widget behavior, and DB-only dashboard rendering remain intact.
|
||||||
49
specs/165-baseline-summary-trust/research.md
Normal file
49
specs/165-baseline-summary-trust/research.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Research: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
|
||||||
|
|
||||||
|
## Decision 1: Derive compact summary claims from existing compare truth and explanation seams
|
||||||
|
|
||||||
|
- Decision: Build the compact summary contract from `BaselineCompareStats` plus `operatorExplanation()` rather than from findings counts or widget-local conditions.
|
||||||
|
- Rationale: The current landing surface already understands explanation family, trustworthiness, coverage statements, reliability statements, reason codes, and next steps. The widget path is currently too lossy because `BaselineCompareStats::forWidget()` collapses the compare state into assignment, snapshot, counts, and last-compare timing. Reusing the richer truth layer ensures summary surfaces do not invent a stronger meaning than the deeper surfaces already carry.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Patch each widget with bespoke `if` conditions around `findingsCount`, `coverageStatus`, and `evidenceGapsCount`. Rejected because that would create another parallel truth model and would drift from the explanation layer over time.
|
||||||
|
- Re-architect compare persistence or introduce new result enums. Rejected because the spec explicitly rules out backend or model rewrites and the current truth signals already exist.
|
||||||
|
|
||||||
|
## Decision 2: Treat zero findings as an output count, never as automatic compliance
|
||||||
|
|
||||||
|
- Decision: Positive all-clear wording is allowed only when the shared summary contract marks the compare result as trustworthy and free from material evidence or coverage limitations.
|
||||||
|
- Rationale: Existing baseline compare explanation logic already distinguishes trustworthy no-result, completed-but-limited, suppressed output, unavailable, and blocked states. The specific false-calm bug is that compact summaries translate `0 findings` into `baseline compliant` even when the reason code, coverage proof, or evidence gaps make that interpretation unsafe.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Keep the `No open drift` wording and just add a small warning badge nearby. Rejected because the primary claim would still be too strong and operators would continue to read the surface as an all-clear.
|
||||||
|
- Remove all positive wording entirely from summary surfaces. Rejected because the product still needs a truthful positive state when the compare result is genuinely decision-grade.
|
||||||
|
|
||||||
|
## Decision 3: Harden the dashboard first because it contains the strongest false-calm claims
|
||||||
|
|
||||||
|
- Decision: Prioritize `BaselineCompareNow` and `NeedsAttention` as the first summary consumers of the new contract.
|
||||||
|
- Rationale: `BaselineCompareNow` currently renders `No open drift — baseline compliant` whenever `findingsCount` is zero. `NeedsAttention` falls back to `Everything looks healthy right now.` when no attention items are generated, even though it does not currently incorporate compare trust or evidence completeness. These are the highest-risk reassurance surfaces because they sit on the tenant dashboard and are read at a glance.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Fix only the Baseline Compare landing page. Rejected because the landing page already has richer explanation semantics and is not the primary false-calm entry point.
|
||||||
|
- Patch the dashboard copy only. Rejected because wording alone would still be backed by inconsistent state-selection logic.
|
||||||
|
|
||||||
|
## Decision 4: Evidence gaps must influence compact summaries even without uncovered-type coverage warnings
|
||||||
|
|
||||||
|
- Decision: Treat evidence gaps as first-class summary-limiting inputs on banners and compact summaries, not merely as deep-diagnostic detail.
|
||||||
|
- Rationale: The current coverage banner shows when coverage is `warning` or `unproven` or when there is no snapshot, but it does not surface evidence-gap-driven partiality when coverage proof exists. The spec explicitly requires evidence gaps to influence summary semantics, so the banner and other compact summaries need the same visibility into evidence limitations that the landing page already has.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Keep evidence gaps only on the landing page and canonical run detail. Rejected because the summary-truth contract would still fail on the dashboard and findings-adjacent surfaces.
|
||||||
|
- Promote all evidence-gap diagnostics into the summary surface. Rejected because compact surfaces need cautionary meaning and next action, not full bucket-level diagnostics.
|
||||||
|
|
||||||
|
## Decision 5: KPI cards stay quantitative and should not be promoted into semantic health claims
|
||||||
|
|
||||||
|
- Decision: Keep dashboard KPI cards as numeric indicators and ensure any semantic reassurance comes only from the shared summary contract on claim-bearing surfaces.
|
||||||
|
- Rationale: The KPI cards currently show counts such as open drift findings and high-severity drift. They are not the source of the false compliant claim, and keeping them numeric avoids unnecessary redesign. The feature should harden claim-bearing summaries, not turn every count card into a mini explanation surface.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Add semantic healthy or compliant captions to KPI cards. Rejected because that would widen the surface area of the problem.
|
||||||
|
- Remove KPI cards from scope entirely. Rejected because the spec includes KPI-adjacent summaries and they still need to remain semantically subordinate to the hardened truth contract.
|
||||||
|
|
||||||
|
## Decision 6: Extend existing Pest and Livewire tests instead of creating a new browser harness
|
||||||
|
|
||||||
|
- Decision: Expand the existing baseline compare widget, landing, stats, and run-detail tests with scenario-specific summary-truth assertions.
|
||||||
|
- Rationale: The repository already has strong feature coverage around `BaselineCompareStats`, explanation families, landing explanations, and the widget claim that currently asserts `No open drift — baseline compliant`. Updating those tests keeps the regression guard close to the implementation seams and preserves the current Sail-first workflow.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Rely on manual QA alone. Rejected because the bug is semantic and cross-surface, so it needs automated regression protection.
|
||||||
|
- Introduce browser tests as the primary guard. Rejected because the affected logic is mainly view-model and rendered-text behavior already well-covered by feature and Livewire tests.
|
||||||
194
specs/165-baseline-summary-trust/spec.md
Normal file
194
specs/165-baseline-summary-trust/spec.md
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# Feature Specification: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
|
||||||
|
|
||||||
|
**Feature Branch**: `165-baseline-summary-trust`
|
||||||
|
**Created**: 2026-03-26
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Spec 165 — Baseline Compare Summary Trust Propagation & Compliance Claim Hardening"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant
|
||||||
|
- **Primary Routes**:
|
||||||
|
- Existing tenant dashboard at `/admin`, including the baseline compare summary widget, needs-attention summary, and drift-related KPI cards
|
||||||
|
- Existing tenant Baseline Compare landing page
|
||||||
|
- Existing tenant findings surfaces that summarize baseline compare coverage or evidence limitations
|
||||||
|
- Existing canonical operation-run drilldowns reached from baseline compare summaries
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Baseline profiles and baseline snapshots remain workspace-owned standards artifacts
|
||||||
|
- Baseline compare results, drift findings, evidence gaps, and tenant-linked operation runs remain tenant-owned operational evidence
|
||||||
|
- This feature changes summary interpretation, claim strength, and operator guidance only; it does not change ownership, persistence, or route identity
|
||||||
|
- **RBAC**:
|
||||||
|
- Existing workspace membership and tenant membership remain required for tenant-context summary surfaces
|
||||||
|
- Existing tenant-view permissions remain authoritative for inspecting baseline, drift, and compare summaries
|
||||||
|
- Existing compare-start permissions remain authoritative for any existing compare action exposed from the landing surface
|
||||||
|
- Non-members remain deny-as-not-found, and members in scope but lacking an action capability remain forbidden for that action
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
|
||||||
|
|
||||||
|
| 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 baseline summaries | Tenant operator | Dashboard summary | Can I safely treat this tenant as aligned, or do I need to review the compare result more closely? | Assigned baseline state, strongest safe summary claim, open drift counts, freshness or availability state, and the clearest next drilldown | Detailed evidence-gap reasons, coverage breakdowns, and raw compare diagnostics | governance result, evidence completeness, freshness, availability | Read-only summary surface | Open Baseline Compare, View findings, View run when available | None introduced by this spec |
|
||||||
|
| Baseline Compare landing summary | Tenant operator | Tenant landing/detail | What does the latest compare actually prove, and what should I do next? | Primary compare meaning, trustworthiness, evidence limitations, drift confirmation state, and one obvious next step | Detailed diagnostics, evidence-gap breakdowns, and low-level supporting facts | governance result, trust or confidence, evidence completeness, lifecycle or freshness | Existing compare-start action remains unchanged; summary itself is read-only | Compare now, View run, Open findings | No new dangerous action; existing guarded actions remain under current confirmation and authorization rules |
|
||||||
|
| Findings coverage banner and adjacent summary copy | Tenant operator | Banner summary | Are current findings enough to trust the absence of drift? | Coverage caveat, evidence limitation, and the safest follow-up cue | Detailed gap reasons and underlying compare evidence | governance result, evidence completeness, availability | Read-only summary surface | Review coverage details, Open findings, View run when relevant | None introduced by this spec |
|
||||||
|
| Canonical operation-run drilldown for baseline compare | Workspace or tenant operator with access | Canonical detail | Is the underlying compare result trustworthy enough to support the summary claim? | Run outcome, artifact truth, result meaning, trustworthiness, and the primary next action | Raw payload fragments, diagnostics, and detailed count breakdowns | execution outcome, artifact truth, evidence completeness, next-action readiness | Read-only drilldown | View run details and follow linked next steps | None introduced by this spec |
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Trust dashboard summary claims (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want summary surfaces to avoid false calm when the last baseline compare is incomplete or only partially trustworthy, so that I do not mistake missing findings for a reliable governance conclusion.
|
||||||
|
|
||||||
|
**Why this priority**: False reassurance on dashboard and compact summaries is the core trust risk. If this is wrong, operators can deprioritize real follow-up work.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering covered summary surfaces for scenarios with zero visible findings but limited confidence, incomplete evidence, suppressed results, or other trust limitations and verifying that none of them present a compliant or all-clear claim.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant with zero visible drift findings but a limited-confidence or evidence-gap-affected compare result, **When** a dashboard summary renders, **Then** it shows a cautionary or review-oriented state instead of `Compliant`, `No drift`, or an equivalent all-clear claim.
|
||||||
|
2. **Given** a tenant with a trustworthy compare result, no contradictory evidence limitation, and no confirmed drift, **When** a dashboard summary renders, **Then** it may show a positive aligned state without contradicting deeper surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Triage constrained compare results safely (Priority: P2)
|
||||||
|
|
||||||
|
As a tenant operator, I want landing and compact baseline compare surfaces to tell me whether the latest result is reliable enough to use and what I should review next, so that I can act appropriately when the result is incomplete, stale, unavailable, or only diagnostically useful.
|
||||||
|
|
||||||
|
**Why this priority**: Operators need more than a count. They need an honest statement of what the compare result does and does not prove.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by opening the covered landing and summary surfaces for incomplete, suppressed, stale, failed, and no-compare-yet scenarios and verifying that each one presents the correct state family and a logical next step.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** no open drift findings and incomplete or partial evidence, **When** the operator opens the landing summary or compact summary, **Then** the surface presents a limited-confidence or incomplete-evidence state with a drilldown hint instead of an all-clear claim.
|
||||||
|
2. **Given** no usable compare result is available because the compare is missing or not ready, or the compare failed and requires investigation, **When** a covered summary surface renders, **Then** it communicates `unavailable`, `in_progress`, or `action required` rather than a healthy posture, with failed compare results mapping to an investigation-oriented action-required state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - See one truth across summary and detail (Priority: P3)
|
||||||
|
|
||||||
|
As an operator moving from dashboard to landing to run detail, I want the same compare result to keep the same underlying meaning across all surfaces, so that deeper inspection confirms the summary rather than correcting it.
|
||||||
|
|
||||||
|
**Why this priority**: The feature fails if different surfaces describe the same compare result in conflicting ways.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by comparing the same covered scenario across dashboard, landing, findings-adjacent summary, and canonical run detail and confirming that the deeper surface is equally cautious or more cautious, but never less cautious.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the same compare result appears on widget, landing, and run-detail surfaces, **When** the operator navigates across them, **Then** the primary claim stays semantically consistent and the deeper surface is never less cautious than the summary.
|
||||||
|
2. **Given** a summary surface cannot honestly give an all-clear claim, **When** it renders, **Then** it exposes a next action or drilldown that leads to the supporting detail needed to resolve the uncertainty.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A compare result may show zero visible findings while still carrying limited confidence, incomplete evidence, suppressed evaluation, or material evidence gaps.
|
||||||
|
- A compare artifact may exist while the result is still too incomplete or untrustworthy to justify a compliance or no-drift claim.
|
||||||
|
- Coverage limitations and evidence gaps may coexist with reassuring counts, and summary surfaces must surface the limitation rather than hiding it behind the counts.
|
||||||
|
- A tenant may have stale compare history, failed compare history, no compare history, no assigned baseline, or no consumable snapshot; each case must land in a distinct stale, action-required, or unavailable state rather than a healthy state.
|
||||||
|
- Different summary surfaces may emphasize different slices of the same result, but none may become more optimistic than the landing or drilldown truth.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph call path, no new mutation workflow, and no new long-running job type. It hardens summary interpretation and operator copy for existing baseline compare evidence. Existing compare execution, confirmation, audit, and run-observability behavior remain authoritative.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** This feature reuses existing compare and operation-run semantics as read surfaces only. The existing Ops-UX 3-surface feedback contract for compare execution remains unchanged. `OperationRun.status` and `OperationRun.outcome` remain service-owned, existing `summary_counts` normalization remains authoritative, and scheduled or system-run behavior is unaffected. Regression tests for this feature must focus on summary claim safety, cross-surface consistency, and evidence-gap propagation rather than new lifecycle behavior.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** This feature does not introduce new authorization rules. It remains in the tenant/admin plane for dashboard, findings, and landing surfaces, with canonical drilldowns continuing to enforce existing workspace and tenant entitlement checks. Non-members remain deny-as-not-found, members remain subject to existing capability checks for guarded actions, and no raw capability strings or role shortcuts may be introduced through summary hardening.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No `/auth/*` handshake path is involved.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Any status-like badge, color, or tone used by a covered summary surface must continue to come from centralized semantics for state, trust, severity, or availability. The feature must not introduce page-local green-success shortcuts that imply a stronger claim than the underlying result supports.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** Covered dashboard widgets, landing summaries, and related operator surfaces must continue to rely on Filament widgets, shared badges, shared alerts, and existing surface primitives rather than introducing a local status language. If a compact custom summary block remains necessary, it must still consume shared status semantics instead of ad hoc page-local styling rules.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target object is the tenant's latest baseline compare posture. Primary operator copy must preserve truthful domain language such as aligned, limited confidence, incomplete evidence, result unavailable, review details, and open findings. Implementation-first terms or false-calming phrases must not appear as primary labels when the result is not decision-grade.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Default-visible content on covered surfaces must remain operator-first, communicating governance result, evidence completeness, freshness or availability, and next action without requiring diagnostic detail. Diagnostics remain secondary and explicitly deeper. Existing compare-start actions keep their current mutation-scope messaging and safe-execution behavior. No new dangerous action is introduced by this feature.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** This feature modifies existing Filament-backed operator surfaces, including a tenant page and summary widgets, without expanding the action inventory. The Action Surface Contract remains satisfied because the landing page keeps its current guarded `Compare now` action, read-only summary widgets remain non-mutating, and the summary hardening changes interpretation rather than action topology. UI-FIL-001 remains satisfied because the feature is expected to reuse existing Filament or shared status primitives. No exemption is required.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** The feature changes summary semantics on existing widgets, banners, and a landing page rather than introducing create or edit screens. Covered surfaces must keep clear sections or cards, meaningful empty or unavailable states, and centralized status presentation. The landing page may continue using its existing custom enterprise layout so long as it preserves the operator-first hierarchy and avoids conflicting summary claims.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-165-001**: The system MUST prevent every in-scope summary surface from showing `Compliant`, `Baseline compliant`, `No drift`, `No open drift`, `All clear`, or a semantically equivalent all-clear claim unless the underlying compare result is trustworthy enough to support that claim.
|
||||||
|
- **FR-165-002**: The primary state of every in-scope baseline or drift summary surface MUST be derived from the combined meaning of drift confirmation, trustworthiness or confidence, evidence completeness, result availability, and any material coverage limitation rather than from findings counts alone.
|
||||||
|
- **FR-165-003**: The system MUST treat `0 findings` or `no open findings` as insufficient on their own to justify a compliance or no-drift claim.
|
||||||
|
- **FR-165-004**: When no open drift is confirmed but the compare result is limited-confidence, incomplete, suppressed, diagnostically useful only, or otherwise not decision-grade, the surface MUST use a cautionary or review-oriented state family instead of a positive all-clear family.
|
||||||
|
- **FR-165-005**: A positive summary state may be used only when a usable compare result is available, the result is trustworthy enough for operator decision-making, no material evidence limitation undercuts the claim, and the result meaning does not contradict the positive claim.
|
||||||
|
- **FR-165-006**: Evidence gaps, resolver limitations, coverage warnings, stale compare conditions, missing compare results, and failed compare results MUST visibly influence the summary state, its wording, or both.
|
||||||
|
- **FR-165-007**: Every in-scope summary surface MUST present one clear primary statement that answers whether drift is confirmed, whether the result is limited or incomplete, whether no usable result is available, or whether follow-up is required.
|
||||||
|
- **FR-165-008**: Every in-scope summary surface that cannot safely present a positive all-clear claim MUST offer a logical next step, drilldown, or review cue that follows directly from the limited or unavailable state.
|
||||||
|
- **FR-165-009**: The system MUST preserve a semantic distinction between `no findings visible`, `no confirmed drift`, `limited confidence`, `incomplete evidence`, `result unavailable`, and `tenant compliant` rather than collapsing them into one visual or linguistic state.
|
||||||
|
- **FR-165-010**: Two different in-scope summary surfaces describing the same compare result MUST NOT present materially conflicting primary claims.
|
||||||
|
- **FR-165-011**: A compact summary surface MAY be equally cautious or more cautious than a deeper landing or drilldown surface, but it MUST never be more optimistic than the deeper truth surface.
|
||||||
|
- **FR-165-012**: Covered summary surfaces MUST consume the existing trust, explanation, evidence, and result-meaning foundations rather than inventing an isolated widget-only truth model.
|
||||||
|
- **FR-165-013**: Existing navigation from summary surfaces to Baseline Compare, findings, or run detail MUST remain intact so that the operator can resolve uncertainty quickly.
|
||||||
|
- **FR-165-014**: Empty, missing, failed, stale, and not-ready compare situations MUST be represented as intentionally distinct state families rather than falling through to healthy, aligned, or compliant language. For this feature, `not-ready` is an umbrella term that MUST resolve into the formal `in_progress` or `unavailable` state family depending on whether an active compare is underway.
|
||||||
|
- **FR-165-015**: Compact dashboard and headline surfaces MUST favor truthful caution over visual calm whenever the result meaning is ambiguous or evidence is materially limited.
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
- **NFR-165-001**: The feature MUST be deliverable without introducing new database tables, new persistent result models, or new outcome enums.
|
||||||
|
- **NFR-165-002**: Existing landing and detail surfaces that already expose richer trust or evidence semantics MUST not be flattened, weakened, or contradicted.
|
||||||
|
- **NFR-165-003**: Existing tenant dashboard, findings, landing, and run-drilldown navigation paths MUST remain stable.
|
||||||
|
- **NFR-165-004**: Existing authorization and tenant-isolation behavior for all covered surfaces MUST remain intact.
|
||||||
|
- **NFR-165-005**: The UI may become more conservative, but it must remain compact and readable rather than turning every limited result into alarm-heavy noise.
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
|
||||||
|
- Rewriting the compare engine, compare execution workflow, or compare persistence model
|
||||||
|
- Introducing new evidence-gap storage structures, new result enums, or a new backend outcome taxonomy
|
||||||
|
- Re-implementing the full baseline compare landing page or operation-run detail page beyond the summary-truth contract they expose
|
||||||
|
- Changing reporting, exports, risk acceptance, exceptions handling, or time-series drift tracking
|
||||||
|
- Redesigning unrelated dashboard or monitoring surfaces outside the baseline or drift summary problem
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
- Existing baseline compare truth, explanation, and evidence foundations are already strong enough that the primary gap is summary propagation rather than backend semantics.
|
||||||
|
- Existing landing and detail surfaces already communicate limited confidence and evidence limitations better than the compact summary surfaces do today.
|
||||||
|
- Operators benefit more from conservative governance language than from visually calm but semantically overstated positive states.
|
||||||
|
- Existing compare-start actions, findings drilldowns, and run drilldowns remain the correct next-step paths and do not need a new execution model for this feature.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Existing baseline compare truth and explanation foundations
|
||||||
|
- Existing evidence-gap and coverage semantics
|
||||||
|
- Existing tenant dashboard, findings, Baseline Compare landing, and canonical operation-run drilldown surfaces
|
||||||
|
- Existing tenant authorization and action-guard patterns
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
|
||||||
|
- Summary surfaces may feel stricter than before, which could initially be perceived as noisier even though the semantics are safer.
|
||||||
|
- Different compact surfaces could drift into slightly different cautionary phrasing if the shared summary contract is not applied consistently.
|
||||||
|
- A surface-level fix that only patches one widget could reintroduce semantic drift elsewhere if shared summary rules are not reused.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||||
|
|
||||||
|
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||||
|
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Baseline Compare landing page | Existing tenant Baseline Compare page | `Compare now` remains the existing header action and keeps current confirmation plus capability gating | Not a record-list inspect surface | None introduced by this spec | None | Existing missing-assignment, missing-snapshot, and unavailable-state guidance remains | `Compare now`, existing `View run` or `Open findings` drilldowns where already available | Not applicable | Existing compare-start audit and run-observability behavior remains unchanged | Action Surface Contract satisfied. This feature changes summary interpretation and wording, not action topology. |
|
||||||
|
| Tenant dashboard summary widgets | Existing tenant dashboard widgets and summary cards | None added by this spec | Existing links to Baseline Compare, findings, and operations remain the inspect path | None | None | Existing dashboard empty or unavailable states remain, but their summary claims must obey the hardened contract | Not applicable | Not applicable | No new audit event | Read-only widget surfaces. No exemption required because no new action surface is introduced. |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Baseline summary claim**: The strongest safe statement a compact surface makes about the tenant's current baseline or drift posture.
|
||||||
|
- **Compare result trust signal**: The combined meaning of trustworthiness, confidence, artifact usability, and result quality that determines how strong a summary claim may be.
|
||||||
|
- **Evidence completeness signal**: The availability, coverage, and evidence-gap posture that can limit or qualify a summary claim even when findings counts look calm.
|
||||||
|
- **Summary state family**: The operator-facing state category used by compact surfaces, such as positive, cautionary, unavailable, or action-required.
|
||||||
|
- **Primary next step**: The clearest follow-up action or drilldown the operator should take when the summary cannot safely present an all-clear claim.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-165-001**: In 100% of covered limited-confidence, incomplete-evidence, suppressed-result, or evidence-gap-affected scenarios, in-scope summary surfaces avoid compliant or all-clear claims.
|
||||||
|
- **SC-165-002**: In 100% of covered trustworthy and fully usable no-drift scenarios, in-scope summary surfaces may present a positive aligned state without contradicting deeper surfaces.
|
||||||
|
- **SC-165-003**: In acceptance review of covered scenarios, the same compare result produces no materially conflicting primary claim across dashboard summary, landing summary, findings-adjacent summary, and run drilldown.
|
||||||
|
- **SC-165-004**: In every covered cautionary or unavailable scenario, an operator can identify the correct next step or drilldown from the visible summary in 10 seconds or less.
|
||||||
|
- **SC-165-005**: In regression review, existing richer landing and detail surfaces continue to expose trust, evidence-gap, and result-meaning nuance without being simplified into findings-only semantics.
|
||||||
216
specs/165-baseline-summary-trust/tasks.md
Normal file
216
specs/165-baseline-summary-trust/tasks.md
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
# Tasks: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/165-baseline-summary-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 Pest and Livewire coverage in `tests/Feature/Baselines/BaselineCompareStatsTest.php`, `tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php`, `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php`, `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php`, and focused DB-only dashboard coverage in `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`.
|
||||||
|
**Operations**: This feature reads existing baseline compare `OperationRun` evidence only. No new run creation, lifecycle transition, notification, or `summary_counts` producer work is introduced.
|
||||||
|
**RBAC**: Existing tenant-view access and existing capability-gated `Compare now` behavior must remain unchanged. Tests must prove no regression for tenant membership, deny-as-not-found semantics, and capability-gated compare execution.
|
||||||
|
**Operator Surfaces**: Dashboard widget, dashboard attention summary, coverage banner, and landing summary must remain operator-first and must never claim a stronger governance state than the canonical run detail.
|
||||||
|
**Filament UI Action Surfaces**: No new actions are added. Existing links and the guarded `Compare now` action must remain intact while summary wording and state selection change.
|
||||||
|
**Filament UI UX-001**: Existing widgets, banner, and landing layout remain compact and sectioned; diagnostics stay secondary.
|
||||||
|
**Badges**: Any changed status-like badge or tone must continue to use centralized semantics rather than page-local success or warning mappings.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and validated as an incremental slice once the shared summary contract is in place.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Summary Contract)
|
||||||
|
|
||||||
|
**Purpose**: Create the reusable support-layer contract that all in-scope summary surfaces will consume.
|
||||||
|
|
||||||
|
- [X] T001 Create the compact summary DTO in `app/Support/Baselines/BaselineCompareSummaryAssessment.php`
|
||||||
|
- [X] T002 [P] Create the shared summary assessor that derives state family, headline, tone, and next action from stats plus explanation in `app/Support/Baselines/BaselineCompareSummaryAssessor.php`
|
||||||
|
- [X] T003 [P] Add support-layer scenario coverage for the shared summary contract in `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Truth Propagation Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Wire the summary contract into the existing baseline truth layer before touching any operator-facing surface.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T004 Expose the shared summary assessment from baseline compare stats in `app/Support/Baselines/BaselineCompareStats.php`
|
||||||
|
- [X] T005 [P] Align compact-surface explanation family, stale-versus-not-ready distinction, and next-action derivation in `app/Support/Baselines/BaselineCompareExplanationRegistry.php`
|
||||||
|
- [X] T006 [P] Keep positive-claim reason semantics explicit for compact summaries in `app/Support/Baselines/BaselineCompareReasonCode.php` and `app/Support/ReasonTranslation/ReasonTranslator.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The support layer now provides one reusable, trust-aware summary assessment for all covered surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Trust Dashboard Summary Claims (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Remove false calm from tenant-dashboard summary surfaces so zero findings no longer implies a compliant or all-clear state when evidence is limited.
|
||||||
|
|
||||||
|
**Independent Test**: Render dashboard summary surfaces for trustworthy, limited-confidence, evidence-gap-affected, stale, failed, in-progress, and unavailable compare scenarios and verify that only genuinely trustworthy no-drift results can present a positive state.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T007 [P] [US1] Replace the false compliant widget assertion and add trustworthy, limited-confidence, stale, failed, in-progress, and unavailable scenarios plus widget drilldown expectations in `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`
|
||||||
|
- [X] T008 [P] [US1] Add dashboard attention-summary coverage for limited-confidence, evidence-gap, stale, in-progress, and unavailable compare states while keeping `Needs Attention` explicitly non-navigational in `tests/Feature/Filament/NeedsAttentionWidgetTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T009 [US1] Feed the shared summary assessment into the dashboard baseline widget in `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`
|
||||||
|
- [X] T010 [US1] Render contract-driven positive, cautionary, stale, unavailable, and in-progress states in `resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php`
|
||||||
|
- [X] T011 [US1] Rebuild dashboard healthy and attention item selection from the shared summary contract in `app/Filament/Widgets/Dashboard/NeedsAttention.php`
|
||||||
|
- [X] T012 [US1] Render truthful caution and next-step fallback copy in `resources/views/filament/widgets/dashboard/needs-attention.blade.php`
|
||||||
|
- [X] T013 [US1] Keep dashboard KPI cards quantitative-only and free of implicit all-clear claims in `app/Filament/Widgets/Dashboard/DashboardKpis.php`
|
||||||
|
- [X] T014 [US1] Run the focused dashboard regression pack in `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Tenant dashboard summary surfaces can no longer issue a false compliant or healthy claim when compare trust is limited.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Triage Constrained Compare Results Safely (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Ensure landing and banner surfaces present limited, suppressed, failed, stale, in-progress, or unavailable compare results honestly and with a clear next step.
|
||||||
|
|
||||||
|
**Independent Test**: Open the landing page and coverage banner for limited-confidence, suppressed-output, evidence-gap, failed, stale-history, no-snapshot, in-progress, and no-result-yet scenarios and verify that each surface stays cautionary, in-progress, or unavailable rather than healthy.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T015 [P] [US2] Extend limited-confidence, suppressed-output, and failed-result landing coverage in `tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`
|
||||||
|
- [X] T016 [P] [US2] Extend zero-findings, evidence-gap, stale-history, in-progress, and unavailable landing coverage in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
|
||||||
|
- [X] T017 [P] [US2] Add coverage-banner truth propagation, failed-result handling, and banner drilldown coverage in `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T018 [US2] Expose the shared summary assessment to landing-page view data in `app/Filament/Pages/BaselineCompareLanding.php`
|
||||||
|
- [X] T019 [US2] Replace findings-only all-clear fallback and keep stale-history, in-progress, and unavailable summary copy distinct in `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||||
|
- [X] T020 [US2] Propagate evidence-gap-aware and limited-confidence summary state into the coverage banner widget in `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php`
|
||||||
|
- [X] T021 [US2] Render compact banner messaging for incomplete evidence, suppressed output, failed-result, and unavailable baseline states in `resources/views/filament/widgets/tenant/baseline-compare-coverage-banner.blade.php`
|
||||||
|
- [X] T022 [US2] Align landing summary translations and operator-facing copy with the hardened claim contract in `lang/en/baseline-compare.php`
|
||||||
|
- [X] T023 [US2] Run the focused landing and banner regression pack in `tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, and `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Landing and banner surfaces now distinguish trustworthy no-drift, limited confidence, incomplete evidence, failed compare, and unavailable compare states.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - See One Truth Across Summary and Detail (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Keep dashboard, landing, banner, and canonical run detail semantically aligned so compact surfaces never out-claim the deeper truth surface.
|
||||||
|
|
||||||
|
**Independent Test**: Compare the same baseline compare scenarios across dashboard, landing, banner, and canonical run detail and verify that compact surfaces are equally cautious or more cautious, never more optimistic, while guardrails and drilldowns remain intact.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T024 [P] [US3] Add cross-surface consistency coverage for shared compare scenarios in `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`
|
||||||
|
- [X] T025 [P] [US3] Extend canonical baseline truth surface assertions so compact summaries never out-claim run detail in `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
|
||||||
|
- [X] T026 [P] [US3] Preserve DB-only tenant dashboard rendering while summary logic changes in `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||||
|
- [X] T027 [P] [US3] Preserve deny-as-not-found access, compare-now capability gating, dashboard, banner, and landing drilldowns into canonical run detail or findings, and the intentionally non-navigational `Needs Attention` summary behavior in `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, and `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`
|
||||||
|
- [X] T028 [P] [US3] Extend reason-translation and why-no-findings regression coverage for compact summary trust semantics in `tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php` and `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T029 [US3] Finalize cross-surface headline, tone, stale-versus-not-ready state families, and next-step selection in `app/Support/Baselines/BaselineCompareSummaryAssessor.php`
|
||||||
|
- [X] T030 [US3] Keep reason-translation fallback labels aligned with summary-versus-detail parity in `app/Support/ReasonTranslation/ReasonTranslator.php`
|
||||||
|
- [X] T031 [US3] Run the focused cross-surface, RBAC, and reason-translation regression pack in `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php`, and `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Compact surfaces and canonical drilldowns now share one truthful semantic contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Final copy alignment, formatting, and focused verification across all stories.
|
||||||
|
|
||||||
|
- [X] T032 [P] Review and align operator-facing summary copy for compliant, no-drift, limited-confidence, stale, in-progress, unavailable, and next-step wording in `resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php`, `resources/views/filament/widgets/dashboard/needs-attention.blade.php`, `resources/views/filament/widgets/tenant/baseline-compare-coverage-banner.blade.php`, `resources/views/filament/pages/baseline-compare-landing.blade.php`, and `lang/en/baseline-compare.php`
|
||||||
|
- [X] T033 Run formatting on touched files with `vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- [X] T034 Run the final focused verification pack from `specs/165-baseline-summary-trust/quickstart.md` against `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php`, `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `tests/Feature/Baselines/BaselineCompareStatsTest.php`, `tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php`, and `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: Starts immediately and establishes the shared summary contract.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup and blocks all user story work until summary truth can be derived centrally.
|
||||||
|
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the MVP by removing false calm from the tenant dashboard.
|
||||||
|
- **User Story 2 (Phase 4)**: Starts after Foundational and can proceed after the shared summary contract is stable; it hardens landing and banner semantics.
|
||||||
|
- **User Story 3 (Phase 5)**: Starts after User Stories 1 and 2 have established the compact summary contract on covered surfaces.
|
||||||
|
- **Polish (Phase 6)**: Starts after all desired stories are complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Depends only on the shared summary contract from Phases 1 and 2.
|
||||||
|
- **User Story 2 (P2)**: Depends on the same shared contract but can be validated independently on landing and banner surfaces.
|
||||||
|
- **User Story 3 (P3)**: Depends on User Stories 1 and 2 because it verifies and finalizes cross-surface semantic consistency.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests should be written or updated before the related implementation tasks and should fail before the feature behavior is considered complete.
|
||||||
|
- Support-layer contract changes should land before widget or landing template rewrites that depend on them.
|
||||||
|
- Focused story-level test runs should complete before moving to the next story.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T002` and `T003` can run in parallel after the DTO shape in `T001` is clear.
|
||||||
|
- `T005` and `T006` can run in parallel after `T004` wires the shared summary seam.
|
||||||
|
- `T007` and `T008` can run in parallel for User Story 1.
|
||||||
|
- `T015`, `T016`, and `T017` can run in parallel for User Story 2.
|
||||||
|
- `T024`, `T025`, `T026`, `T027`, and `T028` can run in parallel for User Story 3.
|
||||||
|
- `T032` can run in parallel with the final verification prep once implementation is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Story 1 dashboard tests in parallel:
|
||||||
|
Task: T007 tests/Feature/Filament/BaselineCompareNowWidgetTest.php
|
||||||
|
Task: T008 tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||||
|
|
||||||
|
# Story 1 implementation split after summary contract wiring:
|
||||||
|
Task: T009 app/Filament/Widgets/Dashboard/BaselineCompareNow.php
|
||||||
|
Task: T011 app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Story 2 landing and banner tests in parallel:
|
||||||
|
Task: T015 tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php
|
||||||
|
Task: T016 tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php
|
||||||
|
Task: T017 tests/Feature/Filament/BaselineCompareCoverageBannerTest.php
|
||||||
|
|
||||||
|
# Story 2 implementation split after test expectations are clear:
|
||||||
|
Task: T018 app/Filament/Pages/BaselineCompareLanding.php
|
||||||
|
Task: T020 app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Story 3 consistency checks in parallel:
|
||||||
|
Task: T024 tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php
|
||||||
|
Task: T025 tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
|
||||||
|
Task: T026 tests/Feature/Filament/TenantDashboardDbOnlyTest.php
|
||||||
|
Task: T027 tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php
|
||||||
|
Task: T028 tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php + tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php
|
||||||
|
|
||||||
|
# Story 3 implementation split after consistency assertions are defined:
|
||||||
|
Task: T029 app/Support/Baselines/BaselineCompareSummaryAssessor.php
|
||||||
|
Task: T030 app/Support/ReasonTranslation/ReasonTranslator.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First
|
||||||
|
|
||||||
|
- Complete Phase 1 and Phase 2.
|
||||||
|
- Deliver User Story 1 as the MVP.
|
||||||
|
- Validate that the tenant dashboard no longer issues false compliant or healthy claims when compare evidence is limited.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
- Add User Story 2 next to harden the landing summary and coverage banner.
|
||||||
|
- Add User Story 3 last to guarantee cross-surface semantic consistency against the canonical run detail.
|
||||||
|
|
||||||
|
### Verification Finish
|
||||||
|
|
||||||
|
- Run Pint on touched files.
|
||||||
|
- Run the focused verification pack from `quickstart.md`.
|
||||||
|
- If broader confidence is needed after focused verification, run the wider suite separately.
|
||||||
@ -37,6 +37,7 @@
|
|||||||
|
|
||||||
$stats = BaselineCompareStats::forTenant($tenant);
|
$stats = BaselineCompareStats::forTenant($tenant);
|
||||||
$explanation = $stats->operatorExplanation();
|
$explanation = $stats->operatorExplanation();
|
||||||
|
$summary = $stats->summaryAssessment();
|
||||||
|
|
||||||
expect($stats->state)->toBe('idle')
|
expect($stats->state)->toBe('idle')
|
||||||
->and($explanation->family)->toBe(ExplanationFamily::Unavailable)
|
->and($explanation->family)->toBe(ExplanationFamily::Unavailable)
|
||||||
@ -44,7 +45,7 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(BaselineCompareLanding::class)
|
->test(BaselineCompareLanding::class)
|
||||||
->assertSee($explanation->headline)
|
->assertSee($summary->headline)
|
||||||
->assertSee($explanation->nextActionText)
|
->assertSee($summary->nextActionLabel())
|
||||||
->assertSee($explanation->coverageStatement ?? '');
|
->assertSee($explanation->coverageStatement ?? '');
|
||||||
});
|
});
|
||||||
|
|||||||
201
tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php
Normal file
201
tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
|
use App\Support\Baselines\BaselineCompareSummaryAssessment;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
|
||||||
|
function createAssignedBaselineTenant(): array
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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 [$tenant, $profile, $snapshot];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('marks trustworthy no-drift results as positive and eligible for an aligned claim', function (): void {
|
||||||
|
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
|
||||||
|
|
||||||
|
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(),
|
||||||
|
'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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
|
||||||
|
|
||||||
|
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_POSITIVE)
|
||||||
|
->and($assessment->positiveClaimAllowed)->toBeTrue()
|
||||||
|
->and($assessment->evaluationResult)->toBe('no_result')
|
||||||
|
->and($assessment->headline)->toBe('No confirmed drift in the latest baseline compare.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps limited-confidence zero findings in a cautionary state', function (): void {
|
||||||
|
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'baseline_compare' => [
|
||||||
|
'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value,
|
||||||
|
'coverage' => [
|
||||||
|
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
|
||||||
|
'covered_types' => ['deviceConfiguration'],
|
||||||
|
'uncovered_types' => ['deviceCompliancePolicy'],
|
||||||
|
'proof' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
|
||||||
|
|
||||||
|
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_CAUTION)
|
||||||
|
->and($assessment->positiveClaimAllowed)->toBeFalse()
|
||||||
|
->and($assessment->evaluationResult)->toBe('suppressed_result')
|
||||||
|
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats failed compare runs as action required with a failed-result semantic', function (): void {
|
||||||
|
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
|
||||||
|
|
||||||
|
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
||||||
|
->and($assessment->evaluationResult)->toBe('failed_result')
|
||||||
|
->and($assessment->nextActionLabel())->toBe('Review the failed run');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats stale compare history as a stale summary state instead of positive', function (): void {
|
||||||
|
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
|
||||||
|
|
||||||
|
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()->subDays(9),
|
||||||
|
'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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
|
||||||
|
|
||||||
|
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_STALE)
|
||||||
|
->and($assessment->positiveClaimAllowed)->toBeFalse()
|
||||||
|
->and($assessment->evidenceImpact)->toBe(BaselineCompareSummaryAssessment::EVIDENCE_STALE_RESULT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps open findings action-required even when compare evidence is otherwise usable', function (): void {
|
||||||
|
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
|
||||||
|
|
||||||
|
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(),
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'baseline_compare' => [
|
||||||
|
'coverage' => [
|
||||||
|
'effective_types' => ['deviceConfiguration'],
|
||||||
|
'covered_types' => ['deviceConfiguration'],
|
||||||
|
'uncovered_types' => [],
|
||||||
|
'proof' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'source' => 'baseline.compare',
|
||||||
|
'scope_key' => 'baseline_profile:'.$profile->getKey(),
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
|
||||||
|
|
||||||
|
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
||||||
|
->and($assessment->findingsVisibleCount)->toBe(1)
|
||||||
|
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS);
|
||||||
|
});
|
||||||
130
tests/Feature/Filament/BaselineCompareCoverageBannerTest.php
Normal file
130
tests/Feature/Filament/BaselineCompareCoverageBannerTest.php
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
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 createCoverageBannerTenant(): array
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows a cautionary coverage banner for suppressed baseline compare results', function (): void {
|
||||||
|
[$user, $tenant, $profile, $snapshot] = createCoverageBannerTenant();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'baseline_compare' => [
|
||||||
|
'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value,
|
||||||
|
'coverage' => [
|
||||||
|
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
|
||||||
|
'covered_types' => ['deviceConfiguration'],
|
||||||
|
'uncovered_types' => ['deviceCompliancePolicy'],
|
||||||
|
'proof' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(BaselineCompareCoverageBanner::class)
|
||||||
|
->assertSee('The last compare finished, but normal result output was suppressed.')
|
||||||
|
->assertSee('Review compare detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an unavailable banner when no current baseline snapshot can be consumed', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'active_snapshot_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(BaselineCompareCoverageBanner::class)
|
||||||
|
->assertSee('The current baseline snapshot is not available for compare.')
|
||||||
|
->assertSee('Review baseline prerequisites');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render the banner for trustworthy no-drift results', function (): void {
|
||||||
|
[$user, $tenant, $profile, $snapshot] = createCoverageBannerTenant();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
'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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(BaselineCompareCoverageBanner::class)
|
||||||
|
->assertDontSee('No confirmed drift in the latest baseline compare.')
|
||||||
|
->assertDontSee('Review compare detail');
|
||||||
|
});
|
||||||
@ -73,14 +73,15 @@
|
|||||||
|
|
||||||
$stats = BaselineCompareStats::forTenant($tenant);
|
$stats = BaselineCompareStats::forTenant($tenant);
|
||||||
$explanation = $stats->operatorExplanation();
|
$explanation = $stats->operatorExplanation();
|
||||||
|
$summary = $stats->summaryAssessment();
|
||||||
|
|
||||||
expect($explanation->family)->toBe(ExplanationFamily::SuppressedOutput);
|
expect($explanation->family)->toBe(ExplanationFamily::SuppressedOutput);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(BaselineCompareLanding::class)
|
->test(BaselineCompareLanding::class)
|
||||||
->assertSee($explanation->headline)
|
->assertSee($summary->headline)
|
||||||
->assertSee($explanation->trustworthinessLabel())
|
->assertSee($explanation->trustworthinessLabel())
|
||||||
->assertSee($explanation->nextActionText)
|
->assertSee($summary->nextActionLabel())
|
||||||
->assertSee('Findings shown')
|
->assertSee('Findings shown')
|
||||||
->assertSee('Evidence gaps');
|
->assertSee('Evidence gaps');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
@ -59,8 +60,11 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$summary = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
|
||||||
|
|
||||||
Livewire::test(BaselineCompareLanding::class)
|
Livewire::test(BaselineCompareLanding::class)
|
||||||
->assertSee(BaselineCompareReasonCode::NoDriftDetected->message());
|
->assertSee($summary->headline)
|
||||||
|
->assertSee('Aligned');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows explicit missing-detail fallback when evidence gaps were counted without recorded subject rows', function (): void {
|
it('shows explicit missing-detail fallback when evidence gaps were counted without recorded subject rows', function (): void {
|
||||||
|
|||||||
@ -7,16 +7,18 @@
|
|||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\OperationRun;
|
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 Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
function createBaselineCompareWidgetTenant(): array
|
||||||
|
{
|
||||||
it('renders the tenant dashboard when a baseline assignment exists (regression: missing BaselineCompareRun model)', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->create([
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'name' => 'Baseline A',
|
'name' => 'Baseline A',
|
||||||
]);
|
]);
|
||||||
@ -34,15 +36,30 @@
|
|||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return [$user, $tenant, $profile, $snapshot];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders a trustworthy no-drift dashboard summary without compliance shorthand', function (): void {
|
||||||
|
[$user, $tenant, $profile, $snapshot] = createBaselineCompareWidgetTenant();
|
||||||
|
|
||||||
OperationRun::factory()->create([
|
OperationRun::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'type' => 'baseline_compare',
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
'status' => 'completed',
|
'status' => OperationRunStatus::Completed->value,
|
||||||
'outcome' => 'succeeded',
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
'initiator_name' => 'System',
|
|
||||||
'context' => [
|
'context' => [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'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,
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'completed_at' => now()->subDay(),
|
'completed_at' => now()->subDay(),
|
||||||
]);
|
]);
|
||||||
@ -55,5 +72,135 @@
|
|||||||
Livewire::test(BaselineCompareNow::class)
|
Livewire::test(BaselineCompareNow::class)
|
||||||
->assertSee('Baseline Governance')
|
->assertSee('Baseline Governance')
|
||||||
->assertSee('Baseline A')
|
->assertSee('Baseline A')
|
||||||
->assertSee('No open drift — baseline compliant');
|
->assertSee('Aligned')
|
||||||
|
->assertSee('No confirmed drift in the latest baseline compare.')
|
||||||
|
->assertSee('No action needed')
|
||||||
|
->assertDontSee('baseline compliant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders limited-confidence zero findings as a cautionary widget state', function (): void {
|
||||||
|
[$user, $tenant, $profile, $snapshot] = createBaselineCompareWidgetTenant();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'baseline_compare' => [
|
||||||
|
'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value,
|
||||||
|
'coverage' => [
|
||||||
|
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
|
||||||
|
'covered_types' => ['deviceConfiguration'],
|
||||||
|
'uncovered_types' => ['deviceCompliancePolicy'],
|
||||||
|
'proof' => false,
|
||||||
|
],
|
||||||
|
'evidence_gaps' => [
|
||||||
|
'count' => 2,
|
||||||
|
'by_reason' => [
|
||||||
|
BaselineCompareReasonCode::CoverageUnproven->value => 2,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(BaselineCompareNow::class)
|
||||||
|
->assertSee('Needs review')
|
||||||
|
->assertSee('The last compare finished, but normal result output was suppressed.')
|
||||||
|
->assertSee('Review compare detail')
|
||||||
|
->assertDontSee('Aligned')
|
||||||
|
->assertDontSee('baseline compliant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders failed compare runs as action required with a run drilldown', function (): void {
|
||||||
|
[$user, $tenant, $profile, $snapshot] = createBaselineCompareWidgetTenant();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
],
|
||||||
|
'failure_summary' => ['message' => 'Graph API timeout'],
|
||||||
|
'completed_at' => now()->subMinutes(30),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(BaselineCompareNow::class)
|
||||||
|
->assertSee('Action required')
|
||||||
|
->assertSee('The latest baseline compare failed before it produced a usable result.')
|
||||||
|
->assertSee('Review the failed run')
|
||||||
|
->assertDontSee('Aligned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders in-progress compare runs without claiming an all-clear', function (): void {
|
||||||
|
[$user, $tenant, $profile, $snapshot] = createBaselineCompareWidgetTenant();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(BaselineCompareNow::class)
|
||||||
|
->assertSee('In progress')
|
||||||
|
->assertSee('Baseline compare is in progress.')
|
||||||
|
->assertSee('View run')
|
||||||
|
->assertDontSee('Aligned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders snapshot-unavailable posture as unavailable rather than healthy', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'name' => 'Baseline A',
|
||||||
|
'active_snapshot_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(BaselineCompareNow::class)
|
||||||
|
->assertSee('Unavailable')
|
||||||
|
->assertSee('The current baseline snapshot is not available for compare.')
|
||||||
|
->assertSee('Review baseline prerequisites')
|
||||||
|
->assertDontSee('Aligned');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
|
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
|
||||||
|
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
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;
|
||||||
|
|
||||||
|
it('keeps widget, landing, and banner equally cautious for the same limited-confidence compare result', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'baseline_compare' => [
|
||||||
|
'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value,
|
||||||
|
'coverage' => [
|
||||||
|
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
|
||||||
|
'covered_types' => ['deviceConfiguration'],
|
||||||
|
'uncovered_types' => ['deviceCompliancePolicy'],
|
||||||
|
'proof' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(BaselineCompareNow::class)
|
||||||
|
->assertSee('Needs review')
|
||||||
|
->assertSee('The last compare finished, but normal result output was suppressed.')
|
||||||
|
->assertDontSee('Aligned');
|
||||||
|
|
||||||
|
Livewire::test(BaselineCompareLanding::class)
|
||||||
|
->assertSee('Needs review')
|
||||||
|
->assertSee('The last compare finished, but normal result output was suppressed.')
|
||||||
|
->assertSee('Limited confidence')
|
||||||
|
->assertDontSee('Aligned');
|
||||||
|
|
||||||
|
Livewire::test(BaselineCompareCoverageBanner::class)
|
||||||
|
->assertSee('The last compare finished, but normal result output was suppressed.')
|
||||||
|
->assertSee('Review compare detail');
|
||||||
|
});
|
||||||
172
tests/Feature/Filament/NeedsAttentionWidgetTest.php
Normal file
172
tests/Feature/Filament/NeedsAttentionWidgetTest.php
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
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 createNeedsAttentionTenant(): array
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows a cautionary baseline posture in needs-attention when compare trust is limited', function (): void {
|
||||||
|
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'baseline_compare' => [
|
||||||
|
'reason_code' => BaselineCompareReasonCode::EvidenceCaptureIncomplete->value,
|
||||||
|
'coverage' => [
|
||||||
|
'effective_types' => ['deviceConfiguration'],
|
||||||
|
'covered_types' => ['deviceConfiguration'],
|
||||||
|
'uncovered_types' => [],
|
||||||
|
'proof' => true,
|
||||||
|
],
|
||||||
|
'evidence_gaps' => [
|
||||||
|
'count' => 2,
|
||||||
|
'by_reason' => [
|
||||||
|
'policy_record_missing' => 2,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(NeedsAttention::class)
|
||||||
|
->assertSee('Needs Attention')
|
||||||
|
->assertSee('Baseline compare posture')
|
||||||
|
->assertSee('The last compare finished, but normal result output was suppressed.')
|
||||||
|
->assertSee('Review compare detail')
|
||||||
|
->assertDontSee('Current dashboard signals look trustworthy.');
|
||||||
|
|
||||||
|
expect($component->html())->not->toContain('href=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps needs-attention non-navigational and healthy only for trustworthy compare results', function (): void {
|
||||||
|
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(NeedsAttention::class)
|
||||||
|
->assertSee('Current dashboard signals look trustworthy.')
|
||||||
|
->assertSee('Baseline compare looks trustworthy')
|
||||||
|
->assertSee('No confirmed drift in the latest baseline compare.')
|
||||||
|
->assertDontSee('Baseline compare posture');
|
||||||
|
|
||||||
|
expect($component->html())->not->toContain('href=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces stale compare posture instead of a healthy fallback', function (): void {
|
||||||
|
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
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()->subDays(10),
|
||||||
|
'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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(NeedsAttention::class)
|
||||||
|
->assertSee('Baseline compare posture')
|
||||||
|
->assertSee('The latest baseline compare result is stale.')
|
||||||
|
->assertSee('Open Baseline Compare')
|
||||||
|
->assertDontSee('Current dashboard signals look trustworthy.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces compare unavailability instead of a healthy fallback when no result exists yet', function (): void {
|
||||||
|
[$user, $tenant] = createNeedsAttentionTenant();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(NeedsAttention::class)
|
||||||
|
->assertSee('Baseline compare posture')
|
||||||
|
->assertSee('A current baseline compare result is not available yet.')
|
||||||
|
->assertSee('Open Baseline Compare')
|
||||||
|
->assertDontSee('Current dashboard signals look trustworthy.');
|
||||||
|
});
|
||||||
@ -5,7 +5,10 @@
|
|||||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
@ -177,3 +180,71 @@ function visibleLivewireText(Testable $component): string
|
|||||||
expect(mb_substr_count($pageText, 'The run finished without a usable artifact result.'))->toBe(1)
|
expect(mb_substr_count($pageText, 'The run finished without a usable artifact result.'))->toBe(1)
|
||||||
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
|
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the compact tenant summary at least as cautious as the canonical run detail for suppressed compare results', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'baseline_compare',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'partially_succeeded',
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'baseline_compare' => [
|
||||||
|
'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value,
|
||||||
|
'coverage' => [
|
||||||
|
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
|
||||||
|
'covered_types' => ['deviceConfiguration'],
|
||||||
|
'uncovered_types' => ['deviceCompliancePolicy'],
|
||||||
|
'proof' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
'errors_recorded' => 2,
|
||||||
|
],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$summary = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
|
||||||
|
$truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh());
|
||||||
|
$explanation = $truth->operatorExplanation;
|
||||||
|
|
||||||
|
expect($summary->stateFamily)->not->toBe('positive')
|
||||||
|
->and($summary->evaluationResult)->toBe('suppressed_result')
|
||||||
|
->and($summary->headline)->toBe('The last compare finished, but normal result output was suppressed.')
|
||||||
|
->and($explanation?->evaluationResult)->toBe('suppressed_result');
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||||
|
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||||
|
->assertSee($explanation?->headline ?? '')
|
||||||
|
->assertSee($explanation?->evaluationResultLabel() ?? '')
|
||||||
|
->assertSee($explanation?->trustworthinessLabel() ?? '')
|
||||||
|
->assertDontSee('No confirmed drift in the latest baseline compare.');
|
||||||
|
});
|
||||||
|
|||||||
@ -19,6 +19,11 @@
|
|||||||
->and($envelope?->absencePattern)->toBe($expectedAbsencePattern)
|
->and($envelope?->absencePattern)->toBe($expectedAbsencePattern)
|
||||||
->and(app(ReasonPresenter::class)->dominantCauseExplanation($envelope))->not->toBe('');
|
->and(app(ReasonPresenter::class)->dominantCauseExplanation($envelope))->not->toBe('');
|
||||||
})->with([
|
})->with([
|
||||||
|
'trustworthy no-drift compare result' => [
|
||||||
|
BaselineCompareReasonCode::NoDriftDetected->value,
|
||||||
|
TrustworthinessLevel::Trustworthy->value,
|
||||||
|
'true_no_result',
|
||||||
|
],
|
||||||
'suppressed compare result' => [
|
'suppressed compare result' => [
|
||||||
BaselineCompareReasonCode::CoverageUnproven->value,
|
BaselineCompareReasonCode::CoverageUnproven->value,
|
||||||
TrustworthinessLevel::LimitedConfidence->value,
|
TrustworthinessLevel::LimitedConfidence->value,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user